Metadata-Version: 2.5
Name: swebot-client
Version: 0.14.6
Summary: Python client for the swebot autonomous coding agent
Project-URL: Homepage, https://github.com/tickup-se/swebot
Project-URL: Repository, https://github.com/tickup-se/swebot
Project-URL: Issues, https://github.com/tickup-se/swebot/issues
Author: Tickup SE
License-Expression: MIT
Keywords: ai,automation,coding-agent,llm,swebot
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# swebot-client

Python client for the [swebot](https://github.com/tickup-se/swebot) autonomous coding agent.

Pure Python — zero dependencies beyond the standard library.

## Install

```bash
# pip
pip install swebot-client

# uv
uv add swebot-client
```

## Quick start

```python
import swebot

print(swebot.ask("explain goroutines"))
```

That is the whole ceremony for a one-shot question against a backend on
localhost. Stream it instead, when you want to watch it arrive:

```python
for token in swebot.stream("explain goroutines"):
    print(token, end="", flush=True)
```

Take a session when the second question depends on the first — a session IS
the context that makes a follow-up a follow-up:

```python
with swebot.session(agent="developer") as s:
    s.send("read parser.go")
    print(s.send("what would you change about it?"))
```

Elsewhere, or behind a token:

```python
swebot.connect("http://10.0.0.5:8080", token="…")   # or set SWEBOT_TOKEN
```

And the full client is one call away — everything below is reachable from it:

```python
client = swebot.Client(timeout=30)
client.set_mode("autonomous")
session = client.create_session(agent="developer")
```

### The same library under two names

`import swebot` is the front door; `swebot_client` is the same objects under
the name existing scripts already import. They can be mixed in one program —
`swebot.TurnFailed` **is** `swebot_client.TurnFailed`, not a copy of it — so
nothing has to be rewritten to use the shorter form, and an `except` clause
keeps catching either way.

```python
from swebot_client import SwebotClient     # unchanged, and staying
```

## Features

- **Streaming** — real-time token-by-token output via SSE
- **Tool callbacks** — observe tool invocations and results
- **Session management** — create, resume, list, delete sessions
- **Mode / model control** — switch modes, models, thinking, reasoning
- **Todos & checkpoints** — read, create, update, delete todos; list/restore checkpoints
- **MCP management** — configure MCP servers programmatically
- **Plugin management** — install, remove, enable, disable plugins
- **AgentSpeak** — multi-agent collaboration (join, leave, send messages)
- **Context control** — compact or clear session context
- **Skills** — list built-in skill agents

## Examples

```python
from swebot_client import SwebotClient, ToolCall

client = SwebotClient("http://localhost:8080")
client.wait_until_ready(timeout=10)

# Set mode to autonomous so the agent executes (not plans)
client.set_mode("autonomous")

# Enable extended thinking
client.set_thinking("high")

# Switch model
client.set_model("claude-opus-4-5")

# Create a session and send a message
session = client.create_session(agent="developer")

def on_tool(tc: ToolCall):
    print(f"  ⚙ {tc.name}")

reply = session.send_with_events(
    "create a hello.py file",
    on_tool_start=on_tool,
)

# Read todos
for todo in session.get_todos():
    print(f"[{todo.status}] {todo.title}")

# Token usage
usage = session.token_usage()
print(f"Tokens: {usage['tokens_in']:,} in / {usage['tokens_out']:,} out ({usage['usage_pct']}%)")
```

### Pipeline example

```python
from swebot_client import SwebotClient

client = SwebotClient()
client.wait_until_ready()
client.set_mode("autonomous")

# Stage 1: generate
s1 = client.create_session()
code = s1.send("Write a Python prime sieve for 0-1000000")

# Stage 2: review
s2 = client.create_session("code_auditor")
review = s2.send(f"Review this code:\n{code}")
print(review)
```

## API reference

### Module-level calls

| Call | Description |
|------|-------------|
| `swebot.ask(message, agent="default", keep=False)` | One message in a session of its own → `str`. A full turn: the agent may act. For a read-only question use `session.ask()` or start the message with `?` |
| `swebot.stream(message, agent="default", keep=False)` | The same, token by token → `Iterator[str]` |
| `swebot.session(agent="default", keep=True)` | A session, as a context manager. Kept by default — pass `keep=False` for a scratch one |
| `swebot.connect(addr, token, …)` | Point those three at a different backend → the `Client` |
| `swebot.client()` | The client they are using |

A backend that is not running gives a `ConnectionError` naming the address and
telling you to start one, rather than a bare `URLError` from urllib.

The session `ask` and `stream` create is **scratch**: you never see its id,
nothing can resume it, and a script asking a thousand questions would leave a
thousand sessions behind. It is discarded when the answer is in — unless the
session is still doing something, in which case it is left alone:

- **a wake-up is scheduled** — the turn asked to be resumed, and the session is
  how it comes back. (The same rule swebot's own one-shot CLI applies.)
- **the turn is still running** — an autonomous or goal continuation outlives
  the stream that started it, so a one-shot that set one going leaves it
  working rather than deleting it mid-turn.
- **anything unknown** — an older backend that does not report wake-ups, a
  request that fails. Deleting is the direction that cannot be undone.

The backend checks again as it deletes, so a wake-up scheduled by another
backend between the check and the delete cannot be lost. `keep=True` holds on
to the session regardless; `swebot.session()` is for when the conversation
itself matters.

### `SwebotClient(addr="http://localhost:8080", token="", timeout=60.0, stream_timeout=None)`

No token needed for a backend on your own machine. If it was started with
`--token` (which a backend bound off loopback requires), set `SWEBOT_TOKEN` in
your environment — the SDK reads it automatically.

`timeout` bounds ordinary requests, so a backend that accepts the connection
and then stops answering cannot hang your script; `wait_until_ready(timeout=…)`
passes its remaining time down to each probe. `stream_timeout` is separate and
defaults to *no deadline*, because a turn streams for as long as it runs and a
silent tool call of several minutes is normal.

### Errors

A turn that fails — the provider erroring, a stall, a cancellation — raises
**`TurnFailed`** from `send()`, `stream()` and `send_with_events()`. Whatever
text arrived before the failure is on the exception as `.partial`:

```python
from swebot_client import TurnFailed

try:
    answer = session.send("refactor the parser")
except TurnFailed as e:
    print("the turn failed:", e)
    print("partial output was:", e.partial)
```

These used to return the empty string, or the partial text, with nothing to
distinguish a failed turn from a short answer.

### `ToolCall`

What the agent did, as `on_tool_start` / `on_tool_result` see it: `id`,
`name`, `input`, `output`, and two flags that are NOT the same question.

| Field | Means |
|-------|-------|
| `is_error` | the call did not succeed |
| `not_run` | there was no call — declined, blocked in plan mode, refused by a plugin, cancelled before it started |

A refused tool is not a failed tool, and anything auditing what the agent
actually did needs to tell them apart. `examples/17_failures.py` shows all
three outcomes.

#### Sessions

| Method | Description |
|--------|-------------|
| `create_session(agent)` | Create a new session → `Session` |
| `get_session(id)` | Resume an existing session → `Session` |
| `list_sessions()` | List all sessions |
| `delete_session(id)` | Delete a session |

#### Agents / Providers / Models

| Method | Description |
|--------|-------------|
| `list_agents()` | List available agents |
| `get_agent(name)` | Get agent details (name, system_prompt, tools) |
| `list_providers()` | List LLM providers |
| `list_models(provider)` | List models for a provider → `{provider, active, models}` |
| `set_model(model, provider)` | Switch model (and optionally provider) |

#### Mode / Thinking / Reasoning

| Method | Description |
|--------|-------------|
| `get_mode()` | Current mode (plan, agent, autonomous) |
| `set_mode(mode)` | Set mode: `"plan"`, `"agent"`, `"autonomous"` |
| `get_thinking()` | Current thinking level |
| `set_thinking(level)` | Set thinking: `""`, `"low"`, `"medium"`, `"high"` |
| `get_reasoning()` | Current reasoning depth |
| `set_reasoning(level)` | Set reasoning: `"low"`, `"medium"`, `"high"` |

#### MCP Servers

| Method | Description |
|--------|-------------|
| `list_mcp()` | List MCP servers with connection state |
| `add_mcp(name, ...)` | Add a new MCP server |
| `update_mcp(name, ...)` | Update an existing server |
| `delete_mcp(name)` | Remove a server |
| `enable_mcp(name)` | Enable a disabled server |
| `disable_mcp(name)` | Disable without removing |
| `reload_mcp()` | Re-read config.yaml and reconnect all enabled servers |

#### Scheduled work

Wake-ups (the agent's schedule tool) and cron jobs run **in the backend**,
with or without a client connected — nothing here blocks on them. List and
cancel them, and watch them fire with `events()`. See `examples/19_scheduled.py`.

### Questions

`session.ask("what does fitFrame do?")` asks a question: it is `send("? " + question)`. The backend answers at once, without the reasoning pre-analysis or the verify pass, and refuses every tool that writes or executes for that turn, so asking never changes files. `session.ask(q, keep_mode=True)` sends `?? q` instead: the same quick answer, but the tools follow the session's mode rather than being blocked, so in autonomous mode the agent may change files if the answer needs it. See `examples/20_questions.py`.

| Method | Description |
|--------|-------------|
| `list_wakeups(session_id="")` | Pending wake-ups, soonest first → `list[dict]` (`id, session_id, message, fire_at, repeat_seconds`) |
| `cancel_wakeup(id)` | Cancel a pending wake-up |
| `list_cron()` | Cron jobs → `list[dict]` (`id, schedule, message, enabled, last_run, next_run`) |
| `add_cron(schedule, message)` | Add a job: `"every 5m"`, `"daily at 10:00"`, `"weekdays at 09:00"`, `"once 2026-12-25 10:00"` |
| `enable_cron(id)` / `disable_cron(id)` | Turn a job on or off, keeping it |
| `remove_cron(id)` | Delete a job |
| `events(kinds=None, session_id="", timeout=None)` | Watch the backend's live events → `Iterator[Event]`; `schedule_fired` / `cron_fired` when one starts, `message_complete` for its reply. Ends at `timeout` |

#### Plugins

| Method | Description |
|--------|-------------|
| `list_plugins()` | List installed plugins |
| `install_plugin(url)` | Install from GitHub (owner/repo) |
| `remove_plugin(name)` | Uninstall a plugin |
| `enable_plugin(name)` | Re-enable a disabled plugin |
| `disable_plugin(name)` | Disable without removing |

#### AgentSpeak (Multi-Agent)

| Method | Description |
|--------|-------------|
| `agentspeak_status()` | Connection status → `{connected, project, role, url}` |
| `agentspeak_projects()` | List visible projects |
| `agentspeak_who()` | List agents in current project |
| `agentspeak_messages(since)` | Fetch recent messages |
| `agentspeak_join(url, project, role)` | Connect and join a project |
| `agentspeak_leave()` | Disconnect from project |
| `agentspeak_send(message, to)` | Send message (broadcast or targeted) |

#### Backend Management

| Method | Description |
|--------|-------------|
| `health()` | Check if backend is reachable |
| `wait_until_ready(timeout)` | Block until backend responds |
| `stats()` | Backend stats (model, tokens, uptime) |
| `version()` | Version info (version, commit, latest) |
| `skills()` | List built-in skill agents |
| `panic()` | Emergency stop — cancel all; the mode stays as it is |
| `backend_inventory()` | Source-owned remote/backend definitions |
| `backend_action(action, **fields)` | Explicit inventory/lifecycle/connect action |
| `deploy_capabilities()` | Authenticated headless deployment capability check; read-only, no credential transfer |
| `update_backend()` | Update the backend to the latest release; it restarts in place (refused while turns run) |
| `shutdown()` | Gracefully stop the backend |

### `Session`

#### Messaging

| Method | Description |
|--------|-------------|
| `stream(message)` | Stream tokens → `Iterator[str]` |
| `send(message)` | Send and get full reply → `str` |
| `ask(question)` | Ask a question: read-only, no reasoning passes → `str` |
| `ask(question, keep_mode=True)` | Ask a question under the session's current mode (`??`): no reasoning passes, but in auto the agent may act → `str` |
| `send_with_events(msg, on_token, on_tool_start, on_tool_result)` | Send with callbacks → `str` |

#### Todos

| Method | Description |
|--------|-------------|
| `get_todos()` | List todo items → `list[TodoItem]` |
| `create_todo(title)` | Create a new todo |
| `update_todo(id, status)` | Update status: pending, in_progress, done |
| `delete_todo(id)` | Delete a todo |

#### Checkpoints

| Method | Description |
|--------|-------------|
| `list_checkpoints()` | List file checkpoints → `list[dict]` |
| `wakeups()` | This session's pending wake-ups → `list[dict]` |
| `restore_checkpoint(id)` | Restore a file to a previous checkpoint |

#### Context

| Method | Description |
|--------|-------------|
| `compact()` | Trigger context compaction |
| `clear()` | Clear all session context |
| `token_usage()` | THIS session's usage → `{tokens_in, tokens_out, context_window, usage_pct}` — falls when the session is compacted |
| `backend_token_totals()` | The backend's cumulative counters across every session |

#### Info

| Method | Description |
|--------|-------------|
| `refresh()` | Reload session data from backend |
| `lsp_diagnostics(server)` | Cached project diagnostic details: file, line, severity and message; no discovery or automatic fixes |
| `message_count` | Number of messages (property) |

## Publishing to PyPI

```bash
cd sdk/python
python3 -m build
UV_PUBLISH_TOKEN=pypi-YOUR-TOKEN uv publish dist/*
```

## Requirements

- Python 3.9+
- A running swebot backend (`swebot --serve`)

## License

MIT

## Code review

Review what a session changed, then choose what to fix. Findings are verified
by an adversarial second pass — only `confirmed` ones can be applied.

```python
from swebot_client import SwebotClient

session = SwebotClient().create_session()
session.send("Add a ParseInt helper to util/parse.go")

rev = session.review(mode="informative")   # or "autonomous"
if rev.running:
    rev = session.wait_for_review(rev.id)

for f in rev.findings:
    print(f.id, f.severity, f.file, f.claim, "confirmed" if f.confirmed else "refuted")

keep = [f.id for f in rev.confirmed if f.blocking]
drop = [f.id for f in rev.confirmed if not f.blocking]
if keep:
    session.apply_review(rev.id, keep)
if drop:
    session.dismiss_findings(rev.id, drop, reason="not now")
```

A session that changed no files returns `scope="out_of_scope"` (or
`"nothing_to_review"`) without calling a model at all.

### Inspect language-server reports

`session.lsp_diagnostics("pyright")` reads the backend's cached project
diagnostics without starting a server or asking the agent to repair files.
Each report includes file, line, column, severity and message. Missing imports
can indicate environment setup, not code defects. See `examples/22_lsp_diagnostics.py`.

### Headless deployment capability

`client.deploy_capabilities()` reads authenticated `GET /api/deploy`.
`headless_deploy >= 1` supports API-key-only `all` and independent remote
headless authentication. This check neither deploys nor transfers credentials.
An older backend's HTTP error is preserved: update it rather than falling back
to legacy deployment that might copy OAuth tokens. See
`examples/23_deploy_capabilities.py`. Use the TUI `/backend remote auth REMOTE` for
interactive, masked login; no browser is launched automatically.


### Interactive deployment HTTP protocol (NDJSON)

See [`examples/python/deploy_http.py`](../../examples/python/deploy_http.py) for a
complete standard-library client. Unlike the CLI `remote_deploy_pipeline.py`, it
keeps the deployment stream open while sending separate answer requests, masks
secret input and cancels on Ctrl+C/EOF. No new public SDK helpers or PyPI release:
**0.14.5** already provides `deploy_capabilities()`.

```sh
export SWEBOT_ADDR=http://localhost:8080
# Set SWEBOT_TOKEN securely if the source backend requires authentication.
python examples/python/deploy_http.py user@host:2222 --port 8080 --auth anthropic
python examples/python/deploy_http.py user@host --all --auth openai
```

All requests use the **source backend's bearer token**. SSH, downloads and saved
credential reads run on that backend, not this client. Use trusted HTTPS or a
protected local transport for credentials.

1. **Preflight:** `GET /api/deploy` must return `{"headless_deploy":1}` (or a
   higher supported version). Reject 404/unsupported responses: a legacy backend
   may ignore auth options and copy OAuth tokens. Capability support does not
   guarantee deployment availability: POST returns **501** if the backend lacks
   its deployment binary callback.
2. **Start:** `POST /api/deploy` with JSON:
   ```json
   {"Target":"user@host:2222","Port":8080,"All":false,"Auth":true,"AuthProvider":"anthropic"}
   ```
   `Target` is the SSH destination (2222 is SSH port). Required `Port` is backend
   port, 1–65535. `All` selects saved Anthropic/OpenAI/Google/Alibaba API keys only,
   never OAuth/login tokens. `Auth` requests independent remote authentication;
   `AuthProvider` selects a provider, or empty string offers a menu. Auth-only
   (`Auth:true, All:false`) skips local credential transfer; `All:true, Auth:true`
   combines transfer and login. Without either, offer saved API keys individually.
3. **Read NDJSON** (one JSON object per line, **not SSE**):
   ```json
   {"text":"Installing binary…"}
   {"text":"Choose provider","question":"opaque-id","input":true,"choices":["anthropic","openai"],"secret":false,"url":"","code":""}
   {"text":"Enter API key","question":"another-id","input":true,"choices":null,"secret":true,"url":"","code":""}
   {"text":"Authorize manually","url":"https://provider.example/authorize","code":"DEVICE-CODE"}
   {"text":"","done":true,"failed":false}
   ```
   Illustrative events, not a fixed sequence or complete menu. Show `text`, URLs
   and device codes; do not auto-open a browser or use the clipboard. Events
   without `question` require no reply. Submit exact displayed choice strings.
   Mask `secret:true` input; never log answers, bodies or authorization headers.
4. **Answer on a separate connection:** `POST /api/deploy/answer` with
   `{"question":"opaque-id","value":"choice or secret"}`; response
   `{"accepted":true}`. Legacy boolean `yes` still supports yes/no prompts;
   string `value` takes precedence. Questions are single-use; expired or consumed
   IDs return **404**. Keep the original streaming request open.
5. **Finish/cancel:** `done:true, failed:false` means deployment completed,
   **not necessarily backend startup**. Installation without usable credentials
   succeeds unstarted: preserve progress and follow its hints. `failed:true`
   carries sanitized failure text. EOF without `done` leaves outcome unknown.
   Closing the stream requests cancellation; the backend also enforces a
   20-minute deadline. Completed installation/settings are not rolled back and
   existing backends are not stopped. Verify remote state before retrying after
   cancellation or transport failure.

### Remote connection and lifecycle HTTP API

These authenticated routes also execute on the source backend. No public SDK
stream/lifecycle helpers exist; the HTTP example uses `urllib`. Existing
`SwebotClient` can use a proxy as its base URL without a new package release.

| Request | Response and semantics |
|---------|------------------------|
| `POST /api/connect` with `{"Target":"user@host:2222","Port":8080}` | Returns `{"id":"…"}`; optional `Token` overrides destination token lookup. Does not start/stop a backend or prove readiness. |
| Requests under `/api/connect/{id}/…` | Forward through private SSH stdio. Client base URL: source URL + `/api/connect/{id}`; use **source token**, proxy supplies destination token. Supports normal API and streaming requests. |
| `DELETE /api/connect/{id}` | Returns `{"closed":true}`; removes proxy only, **does not stop remote backend**. |
| `POST /api/deploy/lifecycle` with `{"Target":"user@host","Port":8080,"Operation":"start"}` | Returns `{"started":true}`. HTTP 502 startup errors can include `code`: `configuration`, `credentials`, `port`, `launch`, `readiness`, `protocol`. |
| Same route with `Operation:"stop"` | Token-free `version`, `instance`, `pid`, `status`, `sessions_known` and optional `latest_session`. Status: `stopped`, `shutting_down`, `not_running`, `exited`. HTTP 200 is not proof shutdown completed; unknown session state is not an empty database. Caller must obtain explicit user consent. |

Connections are process-local, expire after 24 hours and require the source
backend to stay up. Requests forwarded through missing/expired proxies return
404; SSH transport failures return 502. Deletion is idempotent and returns
`{"closed":true}` even for a missing or expired ID. Reconnect after expiry/source restart. Proxy deletion is not backend
shutdown. The CLI pipeline demonstrates tasks against a **running** destination;
installation-only success is not proof it is ready for those tasks.

### Named backend inventory (0.14.6)

GET /api/backends returns remotes (name -> SSH target) and backends (REMOTE/NAME ->
name/remote/workspace/port). POST /api/backends/action takes action and fields:
remote-add (name,target), remote-remove/remove (name), save/create-local (backend,
optional create_directory; create-local accepts auto_port to skip occupied/saved ports), refresh (optional remote name), prepare/start/stop/logs/
connect (name; create_directory for prepare). Stop is explicit caller authorization;
HTTP does not add TUI consent. Refresh returns running/stopped/unreachable per key.
Logs returns bounded logs; connect returns proxy id using source bearer token.
Existing DELETE /api/connect/{id} closes proxy, not executor. New named databases
are isolated; old data untouched. See examples/24_named_backends.py.

Existing HTTP/CLI clients remain compatible despite TUI slash consolidation.
Deployment NDJSON adds optional Backend definition and InstallOnly; clients
provide workspace and answer directory/credential decisions explicitly.

Authenticated SDK requests and streams reject HTTP redirects rather than forward
bearer credentials. Use the backend URL directly, including its proxy base path.
