Metadata-Version: 2.5
Name: swebot-client
Version: 0.14.18
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.connect("unix:///path/swebot.sock")` | A backend started with `--bind unix:///path/swebot.sock`, over its Unix socket. `SwebotClient("unix:///…")` takes the same address; every method works the same. See `examples/26_unix_socket.py` |
| `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(here_only=False)` | List sessions → `list[dict]`, each with `workdir` (where it was created), `here` (that is the backend's directory) and `name` (the backend name it last ran under, when it had one); `here_only=True` keeps just those |
| `prompt_history(limit=None)` | Prompts a person typed, across **all** sessions, newest first and each once → `list[str]` (the TUI's ↑ history; default 500, at most 5000) |
| `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/uninstall action |
| `lsp_status()` | Language servers on the backend: running ones, and for each missing one its language name, aliases and exact install commands |
| `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` |
| `send(message, on_question=fn)` | The same, answering the agent's `ask_user` questions with `fn(question, choices) -> answer` — also those of the run's continuation steps after `send` returns, until the run ends (0.14.16) |
| `wait_until_idle(timeout=None)` | Wait until the session runs nothing; `True` once idle (0.14.16) |
| `pending_questions()` | The `ask_user` questions this session waits on → `[{id, question, choices, …}]` (0.14.16) |
| `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 |
|--------|-------------|
| `fork_points()` | The prompts the session can be forked in front of → `list[dict]` (`seq, created_at, source, preview, content`) |
| `fork(seq)` | A NEW session with the history before the prompt at `seq` — take an instruction back; this one is unchanged → `Session`. See `examples/10_sessions.py` |
| `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`.

### Which language servers are missing, and how to install them

`client.lsp_status()` reads `GET /api/lsp/status`. Each `availability` entry
names the language (`"name": "python"`, `"aliases": ["py"]`) and, when the
server is missing, `install`: exact commands, best first, each marked `found`
when that tool is on the backend's PATH (the backend's machine, which need not
be yours). Nothing is installed. See `examples/31_lsp_status.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.

### Remote uninstall

Existing `backend_action()` also supports uninstall on an updated backend/target:

```python
# Destructive operation: call only after obtaining user confirmation.
# Stop target swebot processes first. Normal mode preserves settings/sessions.
client.backend_action("uninstall", name="lab", target="user@host",
                      full=False, confirmed=True)
# full=True permanently removes standard swebot credentials, settings and databases.
# Workspace files/external databases and source inventory are not deleted.
```

The target must exactly match `backend_inventory()["remotes"]["lab"]`.
Errors are not converted to success or retried with broader deletion.
No new SDK method or package release is required for this action.

## Ask mode (0.14.16)

`ask` is the backend's fourth mode, next to `plan`, `agent` and `autonomous`. It runs like autonomous — no tool approvals, and the agent keeps going until it is done — but a question the agent asks with its `ask_user` tool waits for an answer, as long as it takes, instead of being answered "decide yourself". A script that is the user answers them:

```python
client.set_mode("ask")
reply = session.send("Add a config file. Ask me which format.",
                     on_question=lambda q, choices: choices[0] if choices else "YAML")
```

In ask mode `send` returns when the first turn hands off to its continuation steps; `on_question` keeps answering in the background until the run ends, so call `session.wait_until_idle()` before switching the mode back or exiting — also when `send` raised: a stream that dropped after the backend accepted the message leaves its run going. An answer the backend did not accept (a dropped connection) is sent again while the question is open. The responder answers only its own run: once another run claims the session it stops, so a later `send` is never answered with an earlier callback. `on_question(question, choices)` returns the answer; a callback that raises still answers ("could not be answered … make your best judgment"), so the turn never waits on a crashed script. Without `on_question` the question waits for another client — the TUI, an AgentSpeak dashboard — or for `client.answer_question(id, answer)` with an id from `session.pending_questions()`. The same goes for plan and agent mode, where `ask_user` has always waited.

Example: [29_ask_mode.py](examples/29_ask_mode.py).

## Structured one-shot output (0.14.17)

`client.complete()` is ONE model request (`POST /api/complete`): no session, no tools, no retry, no repair, no continuation. Session turns cannot promise that — a turn waits out an overloaded provider and asks again, resends with a lower reasoning effort when one is rejected, and retries an empty answer — so a script that must know what it paid for, or whether the provider enforced its JSON, uses this:

```python
r = client.complete("Label: 'great battery'", json_schema=schema,
                    schema_name="label", max_output_tokens=2000)
if r.ok:
    print(r.parsed, r.attempts, r.usage.output_tokens, r.usage.reasoning_tokens)
else:
    print(r.status, r.error or r.validation_errors)
```

- **`status`** — `ok`, `invalid_json`, `schema_mismatch`, `refusal`, `truncated`, `incomplete`, `error` or `unsupported`. Failures are results, not exceptions; only a malformed request (HTTP 400) raises. A stream that ends before the provider says why it stopped is `incomplete`, never `ok`.
- **`json_schema`** — with `schema_mode="strict"` (default) the provider must enforce it, or the request is refused before generation (`status="unsupported"`, `attempts=0`). `schema_mode="validate"` falls back to generating unconstrained and validating locally. `schema_enforcement` says which happened (`provider_enforced` / `local_validation_only`). The reply is always validated locally too; `output` is the raw text, never repaired — a fenced or chatty reply is `invalid_json`.
- **`max_output_tokens`** — a provider-side cap; a provider that cannot enforce one refuses the request instead of running uncapped. Reasoning is generated INSIDE the cap on every current provider (`output_cap["reasoning_counts"]`), so a small cap can be spent entirely on thinking and end `truncated` with no text.
- **`attempts`** — HTTP requests that reached the provider, counted at the transport: 1, or 0 when refused. `request_ids` and `response_id` are the provider's ids where it sends them.
- **`usage`** — a `Usage`: `input_tokens`, `cached_input_tokens` (part of input), `output_tokens`, `reasoning_tokens` (part of output). `None` means not reported — Anthropic does not report reasoning separately, so it is `None` there, not 0; a response without a cache breakdown has `cached_input_tokens=None`, a stream cut off before its output total `output_tokens=None`. Figures the provider gave before a failure are kept. `partial` marks a failed, cancelled or interrupted request.

`client.completion_capabilities()` reports what the backend's provider enforces. Today: OpenAI (API key) and Codex (ChatGPT login) enforce both schema and cap through the Responses API; Anthropic (API key and Claude login) enforces the cap through `max_tokens` on every model and the schema through `output_config.format` on Claude Haiku 4.5, Sonnet 4.5, Opus 4.1 and later. Copilot, Gemini, Alibaba and local endpoints report `unsupported` for both until verified — use `schema_mode="validate"` there. The request uses the backend's current model and reasoning setting.

`Session.last_usage` is the same `Usage` for the last `send()`: every agent run its stream reported (a verify or compaction rerun, a continuation step), each summed over its model calls; reasoning forks and the verify critic are not included. There `reported=False` means swebot estimated the figures (about 4 characters a token), and `reasoning_tokens` is `None` unless every call reported it. The usage stays `partial=True` until the turn's `stream_end` arrives, so a turn cut off after a completion (EOF, timeout) never reads as whole.

Example: [30_structured_output.py](examples/30_structured_output.py).

## Owner/observer attachments (0.14.8)

`Session.attach(client_id=None, observe_only=False)` negotiates owner/observer
access and returns a scoped bearer token. It never replaces the shared client's
backend credential. Create a separate `SwebotClient` with returned token, then a
`Session` on that client. For SSH proxy URLs the token authenticates both source
proxy and destination session. Never print it or share administrative credentials
as though they were observer credentials.

`state()` exposes normal runtime snapshot with `access_mode`. `renew_access()`
renews scoped access without promotion. `release_access()` releases it without
cancelling work or deleting history. Retain a unique client_id across reconnects;
do not share one identifier between independent clients. Lease expires after
90 seconds. SDK callers own their renewal loop (every 20 seconds) and release on
exit; no hidden background heartbeat is started.

Example: [25_observe_session.py](examples/25_observe_session.py). This performs no
model calls or mutations beyond attachment maintenance.

## Files between the backend and this machine (0.14.12)

The TUI's "@" popup can save a backend file here and upload one there; a
script does the same with three methods on `SwebotClient`. Backend paths are
relative to the backend's working directory.

| Method | What it does |
|---|---|
| `file_stat(path, include_ignored=False)` | `name`, `dir`, `size` (bytes), `files` — what a download would copy. A directory counts what the popup shows: no `.git`, git-ignored files only with `include_ignored` |
| `download(path, dest_dir=".", include_ignored=False, timeout=None)` | Copies a file or directory into `dest_dir` and returns the local path. A directory arrives as one `.tar.gz` stream and is unpacked as a folder; an entry that would land outside `dest_dir` is refused |
| `upload(local_path, dir=".", timeout=None)` | Copies a local file or directory (without `.git`; hard-linked names each as a file) into `dir` on the backend; returns `path` (what an `@` mention names), `name` and `size`. A read-only attachment gets HTTP 403 |

Nothing is ever overwritten on either side: a second `report.pdf` becomes
`report (2).pdf`. Both write under a hidden partial name and rename only when
the copy is complete. `timeout` defaults to none: it is how long one read
or write on the connection may wait, not a bound on the whole copy, which
takes as long as it takes. The HTTP endpoints are `GET /api/files/stat`,
`GET /api/files/download` (a file supports `Range`, so a client can resume)
and `PUT /api/files/upload?dir=&name=[&kind=dir]`.

Example: [27_files.py](examples/27_files.py).

### Images for the model (0.14.15)

| Method | What it does |
|---|---|
| `upload_image(image, timeout=None)` | Saves an image (a local path, or the bytes) on the backend, as the TUI's `Ctrl+V` does with a screenshot; returns `path`, `media_type`, `size`. Put the path in a message — the model opens it with `read_file`, which returns PNG, JPEG, GIF and WebP files as images (scaled to at most 2000 px). Anything the model could not be shown is refused (HTTP 415/422) |

The endpoint is `POST /api/images` with the image as the body. Saved images
live in `~/.swebot/pastes/images/` on the backend and are deleted after 30
days.

Example: [28_images.py](examples/28_images.py).

Cron responses add RFC3339 `next_run_at`/`last_run_at`; convert those instants to
client-local time. Legacy fields remain backend-local, ambiguous strings.
