07 - Client mirror: Python ServerClient and the Node CLI
Status: archive. Not the current specification.
Status: target design, not implemented. The shipped server uses endpoint ids and one policy engine; see README section 1 for what exists and section 2 for what does not.
Part of Server SDK v2. Rule R5. Supersedes
client-server-alignment.mdsections on naming/errors (its gap analysis of envd vs server remains valid input). v2.1: ONE client error range (E3100-E3111,E9xxxis dropped, in-flight class names kept), a wire-code -> exception table,AuthorizationvsX-Access-Tokenprecedence, a machine-checkable parity ledger covering every op (including the 18 ops,browser.*andext.*the feasibility review found missing),Sandbox.serverversus the declarativesandbox.server.startnamespace. Findings: IF-07, IF-10, IF-11, IF-17, IF-26, IF-27, IF-28, N-3, N-8.
1. Principle
The client does not hand-write a method per endpoint. Both clients are generated from the server catalog
(operations.json, 02 section 7.1), have the same namespace tree as the server, and are
checked by contract tests that replay every catalog operation against a real server.
sb = await Sandbox.connect(sandbox_id) # existing
srv = sb.server # ServerClient bound to the sandbox's port 9000 (gateway headers added)
await srv.files.list("/tmp") # op files.list
data = await srv.files.download("/tmp/a.txt") # bytes (decoded); op files.download
await srv.commands.run("ls /tmp") # op commands.run -> CommandResult-like
await srv.agents.codex.run("fix the failing test") # op agents.codex.run
sess = await srv.agents.codex.sessions.create(environment={"type": "self_hosted", "id": "env_local", "remote_url": srv.base_url},
input="fix the failing test") # same shape as the official SDK (05 section 5); no `workspace=` shorthand
await srv.custom.grade(submission="...") # user-defined op, dynamic tree from GET /ops
await srv.call("custom.grade", submission="...") # generic escape hatch (also takes ops.* nodes)
- Method name = last ID segment; namespace attributes mirror the ID:
srv.system.env.get(),srv.agents.claude_code.run(...). - Sync twins:
srv.files.list_sync("/tmp")for every method (library convention). - IDs are importable:
from agent_sandbox import opsreturns the sameOpReftree as the server's (ops.files.list), so policy code, client calls and docs share spelling. (agent_sandbox.opsis generated from the catalog; the server'sagent_sandbox.server.opsis the same declaration; the contract test asserts equality of the two trees.) srv.describe(),srv.ops(tree),srv.capabilities()for discovery.- Parameters are keyword-friendly and typed; results are
TypedDicts (dict at runtime) (v2.1, IF-11): the generatedServerClientmay emit the existing pydantic result models ofmodels/server.py(the working tree already uses them for 48 endpoints) instead ofTypedDicts; the generator has one switch (--result-style typeddict|pydantic, defaultpydanticfor the client wheel because pydantic is already a client dependency; the server package stays stdlib-only). Both are dict-compatible at the boundary. The base exception class is the existingSandboxError(there is noEasySandboxError). - Compat aliases of the in-flight client (v2.1). The working tree's hand-written
ServerClient(api/server_client.py) keeps its method names as deprecated aliases of the generated tree for two minors:srv.process.shell=srv.commands.run,shell_stream=commands.stream,srv.files.upload_legacy/download_legacystay,srv.system.env()/set_env()=system.env.get/set,srv.devtools.run_code=devtools.code.run,srv.pty.*=srv.terminal.*. A test asserts each alias resolves to the same op id.
2. Generation and drift control
server registry --(agent-sandbox-server gen-client --lang python)--> client-sdk/src/agent_sandbox/api/server/_generated.py
--(--lang ts)------------------------------------> cli/src/server/generated/catalog.ts, client.ts
--(--lang openapi)-------------------------------> catalog/openapi.json (shipped, also a release asset)
--(describe --markdown)--------------------------> tables in 04-builtin-modules.md
- Generated files carry a header with
catalog_version, the registry digest, and "DO NOT EDIT". They are committed, so the client wheel has no build-time dependency on the server package and no runtime import of it (AGENTS.mdlayering stays). - Drift test (monorepo CI, needs both packages installed from source): regenerate in memory, compare with committed files -> fail on diff.
Separate contract test starts a server with
full_access, plus fake agent binaries, and for each catalog op calls the generated client method with schema-derived minimal arguments fromtests/contract/cases.py(hand-written argument fixtures only; one per op), asserting status class, result keys, and client/server error mapping. A new op without a case fails the suite (case coverage test). - Dynamic fallback for ops the generated client does not know (custom ops, newer server):
ServerClientfetchesGET /opslazily on first unknown attribute access and builds callables from the schema; arguments are validated client-side; results are returned as dicts. This is also howsrv.custom.<name>works without codegen. OneGET /opsper client instance, cached for the session.
3. Capability awareness (no hidden traffic)
caps = await srv.discover() # explicit GET /capabilities (never implicit)
caps.can(ops.files.download) # bool, for this principal
caps.limit(ops.files.download, "max_bytes") # effective limit or None
caps.paths(ops.files.download) # PathScope summary (roots, readonly) if the server exposes scopes
caps.require(ops.files.upload) # raises OperationNotEnabledError (E3103) before any network call
await caps.check(ops.files.download, path="/etc/passwd") # server-side dry-run: core.check
caps.legacy # True for pre-v2 servers (no `ops` key): groups mapped through the golden lists in 04
- Methods consult a populated cache and fail fast with
OperationNotEnabledError/PolicyDeniedError-typed errors only whendiscover()was called; server responses stay authoritative and produce the same exceptions. No request is made on the user's behalf except the call itself. ServerClient(strict=True)makesdiscover()mandatory before calls (useful in agents that must not probe).- Discovery body detection (v2.1, IF-26). The server serves a union body (02 section 7). The client detects in this order:
ops(v2.1 server) ->endpoints(in-flight v1.x capability server) ->groups(v1 server; golden lists in 04).ServerCapabilities.legacyis true only for the last. The compat keys stay for two minors, then are removed in acatalog_versionbump. - Credentials (v2.1, IF-17). The client sends one credential:
Authorization: Bearer <token>.X-Access-Tokenis sent only whenServerClient(legacy_auth=True)(needed for pre-v2.1 servers; the discovery of a v1 server switches it on automatically). Both headers are never sent together. The server rule (03 section 5): Bearer wins, the legacy header is an alias, both present and different ->401 auth.conflicting_credentials(client classServerAuthError, message names the conflict), both equal -> accepted. The gateway's own header (X-Access-Tokenfor the Alibaba gateway) is added by the transport and never forwarded as the server credential; when a deployment sits behind such a gateway, setgateway_token=(transport header) andtoken=(server Bearer) separately.
4. Errors: ONE range, E3100-E3111 (v2.1, IF-10)
The repository has three schemes today: the working tree uses E9000-E9005 (models/errors.py, header "E9xxx = In-sandbox server API errors"), the in-flight capability design
proposed E8xxx, and v2.0 proposed E3100-E3111. Decision (D29): the server plane uses E3100-E3111 only, next to the execution family (E3000-E3006);
E8000-E8030 stays exclusively with the providers package (providers/), E9xxx is removed from every plan and document.
| Code | Class (existing in-flight name kept) | Wire code / status |
Also subclasses (compat) | Replaces |
|---|---|---|---|---|
| E3100 | ServerAPIError (base), ServerArgumentError(ServerAPIError, ValueError) |
any unmapped, args.* 400 |
SandboxError |
E9000 |
| E3101 | ServerAuthError |
auth.* 401, 429 auth.rate_limited, auth.conflicting_credentials |
AuthenticationError |
E9001 |
| E3102 | PolicyDeniedError, FilePolicyDeniedError |
policy.* 403 (incl. policy.scope_required, policy.path_*, policy.command_*, policy.url_*), request.host_denied, request.origin_denied |
PermissionDeniedError (file variant) |
E9002 |
| E3103 | EndpointNotAvailableError (alias OperationNotEnabledError) |
op.not_enabled 404, op.unknown_route, agents.not_found on unmounted adapters |
CapabilityNotSupportedError (E3004) |
E9003 |
| E3104 | PayloadTooLargeError |
limit.max_bytes, limit.request_too_large 413 |
E9004 | |
| E3105 | RateLimitedError |
limit.rate, limit.concurrency 429 (retry_after) |
E9005 | |
| E3106 | ServerInstallError |
install.* (phase, retry_after, job_id; 202 is a result, not an error) |
new | |
| E3107 | ServerAgentError |
agents.* |
new | |
| E3108 | ServerUnavailableError |
server.unavailable, request.busy, connection errors, 502/503 without code |
ConnectionError_ |
new |
| E3109 | ServerInternalError |
server.internal 500 |
new | |
| E3110 | ContractMismatchError |
raised client-side: op unknown to the server's catalog while the generated client has it | new | |
| E3111 | ServerConfigError |
raised client-side: strict-mode misuse, ambiguous credentials configured locally, bad ServerClient(...) arguments |
ValueError |
new |
Attributes (kept + extended). The working tree's ServerAPIError(status_code, error_type, error_code, endpoint) is kept unchanged (existing user code reads them); v2.1 adds
op (the op id; equals endpoint for in-flight servers), request_id, details (dict), status (alias of status_code). The attribute .code remains the E-code
("E3102"), never the wire code; the wire code is .error_code. This removes the v2.0 clash of .code meaning two things.
Wire code -> exception (N-8). Most specific first:
Wire code (03 section 11) |
HTTP | Exception | E-code |
|---|---|---|---|
auth.required, auth.invalid, auth.conflicting_credentials |
401 | ServerAuthError |
E3101 |
auth.rate_limited, limit.rate, limit.concurrency |
429 | RateLimitedError (retry_after) |
E3105 / E3101 for auth.rate_limited |
op.not_enabled, op.unknown_route |
404 | EndpointNotAvailableError |
E3103 |
args.* |
400 | ServerArgumentError |
E3100 |
policy.scope_required, policy.denied_by_rule, policy.denied_by_hook, policy.hook_error, policy.hook_bad_return, policy.not_permitted |
403 | PolicyDeniedError |
E3102 |
policy.path_*, policy.symlink_denied, policy.hardlink_denied, policy.not_regular_file, policy.read_only |
403 | FilePolicyDeniedError |
E3102 |
policy.command_*, policy.executable_not_allowed, policy.shell_not_allowed, policy.env_var_denied, policy.url_* |
403 | PolicyDeniedError |
E3102 |
request.host_denied, request.origin_denied, request.bad_target, request.bad_content_type |
403 / 400 / 415 | PolicyDeniedError (403) / ServerArgumentError |
E3102 / E3100 |
limit.max_bytes, limit.request_too_large |
413 | PayloadTooLargeError (limit) |
E3104 |
install.* (non-202) |
424 / 502 / 503 | ServerInstallError |
E3106 |
agents.*, OpenAI-wire error.type |
4xx/5xx | ServerAgentError |
E3107 |
request.busy, server.unavailable, server.shutting_down, transport failure |
503 | ServerUnavailableError |
E3108 |
server.internal |
500 | ServerInternalError |
E3109 |
legacy v1 body without code |
any | by status: 401 auth, 403 policy, 404 endpoint_disabled -> E3103, 413, 429, else base |
by status |
What in-flight code must change (hand-over list):
1. client-sdk/src/agent_sandbox/models/errors.py: header comment E9xxx -> E31xx; code = "E9000..E9005" -> "E3100..E3105"; add ServerInstallError, ServerAgentError,
ServerUnavailableError, ServerInternalError, ContractMismatchError, ServerConfigError, ServerArgumentError; add op, request_id, details, status; export in models/__init__.py.
2. client-sdk/src/agent_sandbox/api/server_client.py (error_from_response): map by the table above (prefers the wire code, falls back to the legacy type/status rules).
3. Tests: tests/test_api/server_e2e/test_server_client_models.py (2 hits), test_server_client_policy.py (1 hit): E9xxx -> E31xx.
4. Docs (en and zh pairs): reference/error-codes.md (9), reference/api-reference.md (4), design/fine-grained-capabilities.md (E8xxx text, 5), design/client-server-alignment.md, design/cli-followups.md, root CHANGELOG.md entry.
5. CLI (/workspace/cli): exit-code mapping uses the wire family, not E-codes; nothing to rename.
6. fine-grained-capabilities.md section 7 proposes E8000-E8005: superseded banner; the providers keep E8000-E8030 untouched. A repository test (tests/test_error_codes_unique.py) asserts that no two classes share an E-code except documented subclass pairs and that no class outside providers/ uses E8xxx and none uses E9xxx.
Specific legacy mappings are preserved: files.* 404 -> FileNotFoundError_; commands.run timeout -> CommandTimeoutError (E3001); unknown named command ->
CommandNotFoundError (E3005, also EndpointNotAvailableError); non-zero exit remains a result, not an exception (as in ProcessError only when the caller asks check=True).
202 install.job is a result: the generated client polls it with srv.agents.<a>.install.wait(job) or surfaces InstallPending(job_id, retry_after) when wait=False.
5. Sandbox integration
Sandbox.serverversus the declarative namespace (v2.1, IF-07). Today the declarative layer hassandbox.server.start(...)/sandbox.server.register.*. The attributeSandbox.serveris one object (ServerNamespace): it forwards attribute access to the generatedServerClienttree (sb.server.files.list(...)) and keeps the declarative methodsstart,register,enable_builtin,restartas members (names that are not op namespaces; the generator refuses to emit a namespace namedstart,register,restart,enable_builtin). A test pins both uses.Sandbox.server->ServerClientfor port 9000 using the sandbox's port URL and gateway headers;Sandbox.server_at(port, token=...)for other ports/tokens; the Bearer token comes fromserver_token=/ABX_SERVER_TOKEN. BothX-Access-TokenandAuthorization: Bearerare sent per transport (server accepts either).Sandbox.start_server(...)(remote start + readiness viahealthcheck) as in the base-image design.Sandbox.custom(name, **kw)andlist_commands()remain unchanged (template mechanism A, server mechanism B) and are re-implemented oversrv.custom.*when the server reports v2; for v1 servers they use the old routes.- Official OpenAI SDK helper:
srv.agents.openai_client()returnsopenai.OpenAI(base_url=<port-url>/v1, api_key=<token>)whenopenaiis installed (lazy import).
6. Parity: client counterpart of every server op (v2.1, IF-27)
Rule: every op has a counterpart on the generated ServerClient tree (same dotted path; R5). Where the experience differs (stream, raw bytes, admin guard, dynamic, reserved,
refused) the row says so in Kind and, where it matters, carries an asymmetry id (A1..A7, listed below). The ledger would be
tests/contract/client_parity.json, generated by make gen and checked by a drift test.
The 18 ops the feasibility review found without a client (files.search, files.upload_stream, files.download_stream, files.archive, process.get, process.output, terminal.list,
terminal.get, system.info, system.env.get, system.env.set, system.ports, system.packages, devtools.git.status, devtools.git.diff, core.openapi, core.reload, core.describe),
all of browser.* and ext.* are rows below.
6.1 Documented asymmetries
| ID | Asymmetry | Reason |
|---|---|---|
| A1 | core.openapi returns raw JSON, not a typed result |
documentation asset |
| A2 | core.reload, system.env.set, process.signal, terminal.create, agents.<a>.install, agents.<a>.sessions.create are explicit-only on the server; the client exposes them but a call to an unmounted one is the normal EndpointNotAvailableError |
policy, not client, decides |
| A3 | system.* has no envd twin; envd metrics is the platform plane |
planes stay separate (no hidden fallback) |
| A4 | devtools.code.run is a different engine than code.run on port 49999 (no contexts) |
documented in 04 |
| A5 | agents.environments.files.* reserved ids: no client method in 0.3.0 |
reserved for policy addressing only (T-4) |
| A6 | official subagents.{retrieve,list_items,turns.*}: the client method raises NotSupportedError locally, the server answers 404 not_found_error |
tier 3 refusal (05 section 4) |
| A7 | custom.<name>, ext.<handler>: no generated method; reached through the dynamic tree from GET /ops or srv.call(id, **args) |
ids are decided by the template author |
6.2 Plane map (envd vs server)
The two planes keep their own method names (sandbox.files.make_dir on envd, sandbox.server.files.mkdir on the server plane, the latter always equal to the op ID verb); the envd twin column is the map.
Applications choose the plane explicitly; there is no transparent fallback between planes (hidden fallbacks would make "was this call policy-checked?" unanswerable). Not mirrored on the server by design:
network, pause/resume, timeout, templates (platform plane), envd signed file URLs (envd only).
6.3 Ledger (generated; do not edit by hand)
| Op id | Client call (generated, same tree) | envd twin | Kind | Note / asymmetry |
|---|---|---|---|---|
agents.codex.install |
srv.agents.codex.install() |
- | admin | explicit-only; 202 + job |
agents.codex.run |
srv.agents.codex.run(prompt, ...) |
- | typed | AgentScope required |
agents.codex.sessions.create |
srv.agents.codex.sessions.create(...) |
- | typed | exec-effect op; AgentScope required |
agents.codex.sessions.send_event |
srv.agents.codex.sessions.send_event(id, event) |
- | typed | exec-effect op |
agents.definitions.create |
srv.agents.definitions.create(...) |
- | typed | official agents.create |
agents.definitions.delete |
srv.agents.definitions.delete(id) |
- | typed | |
agents.definitions.get |
srv.agents.definitions.get(id) |
- | typed | |
agents.definitions.list |
srv.agents.definitions.list(**page) |
- | typed | list envelope; auto-paging iterator |
agents.definitions.update |
srv.agents.definitions.update(id, ...) |
- | typed | |
agents.environments.create |
srv.agents.environments.create(...) |
- | typed | |
agents.environments.files.create |
- |
- | reserved | reserved id (T-4); not implemented in 0.3.0 (asymmetry A5) |
agents.environments.files.list |
- |
- | reserved | reserved id (T-4); not implemented in 0.3.0 (asymmetry A5) |
agents.environments.get |
srv.agents.environments.get(id) |
- | typed | |
agents.environments.list |
srv.agents.environments.list(**page) |
- | typed | |
agents.models.list |
srv.agents.models.list() |
- | typed | |
agents.runtimes.doctor |
srv.agents.runtimes.doctor() |
- | typed | |
agents.runtimes.list |
srv.agents.runtimes.list() |
- | typed | was agents.list (N-1); flags experimental adapters |
agents.sessions.artifacts.content |
srv.agents.sessions.artifacts.content(id, aid) |
- | raw | bytes |
agents.sessions.artifacts.delete |
srv.agents.sessions.artifacts.delete(id, aid) |
- | typed | |
agents.sessions.artifacts.get |
srv.agents.sessions.artifacts.get(id, aid) |
- | typed | |
agents.sessions.artifacts.list |
srv.agents.sessions.artifacts.list(id) |
- | typed | |
agents.sessions.create |
srv.agents.sessions.create(...) |
- | typed | two-level gate with agents..sessions.create |
agents.sessions.delete |
srv.agents.sessions.delete(id) |
- | typed | |
agents.sessions.get |
srv.agents.sessions.get(id) |
- | typed | |
agents.sessions.list |
srv.agents.sessions.list(**page) |
- | typed | |
agents.sessions.list_items |
srv.agents.sessions.list_items(id, **page) |
- | typed | |
agents.sessions.send_event |
srv.agents.sessions.send_event(id, event) |
- | typed | official events.create (202, N-2) |
agents.sessions.stream_events |
srv.agents.sessions.stream_events(id) |
- | stream | SSE; idle-open |
agents.sessions.subagents.get |
raises NotSupportedError |
- | refused | server 404 not_found_error (asymmetry A6) |
agents.sessions.subagents.list |
srv.agents.sessions.subagents.list(id) |
- | typed | empty list envelope |
agents.sessions.subagents.list_items |
raises NotSupportedError |
- | refused | asymmetry A6 |
agents.sessions.subagents.turns.get |
raises NotSupportedError |
- | refused | asymmetry A6 |
agents.sessions.subagents.turns.list |
raises NotSupportedError |
- | refused | asymmetry A6 |
agents.sessions.subagents.turns.list_items |
raises NotSupportedError |
- | refused | asymmetry A6 |
agents.sessions.traces.list |
srv.agents.sessions.traces.list(id) |
- | typed | empty list envelope in 0.3.0 |
agents.sessions.turns.get |
srv.agents.sessions.turns.get(id, turn_id) |
- | typed | |
agents.sessions.turns.list |
srv.agents.sessions.turns.list(id) |
- | typed | |
agents.sessions.turns.list_items |
srv.agents.sessions.turns.list_items(id, turn_id) |
- | typed | |
agents.sessions.update |
srv.agents.sessions.update(id, ...) |
- | typed | |
browser.click |
srv.browser.click(selector) |
- | typed | |
browser.console |
srv.browser.console() |
- | typed | |
browser.content |
srv.browser.content(type) |
- | typed | |
browser.evaluate |
srv.browser.evaluate(script) |
- | typed | |
browser.navigate |
srv.browser.navigate(url) |
- | typed | UrlScope-checked |
browser.pdf |
srv.browser.pdf() |
- | raw | bytes |
browser.screenshot |
srv.browser.screenshot() |
- | raw | bytes |
browser.type |
srv.browser.type(selector, text) |
- | typed | |
commands.run |
srv.commands.run(command|argv) |
commands.run | typed | in-flight alias srv.process.shell is a deprecated alias |
commands.stream |
srv.commands.stream(command|argv) |
commands.stream | stream | in-flight alias srv.process.shell_stream |
core.capabilities |
srv.discover() / srv.capabilities() |
- | typed | never implicit (07 section 3) |
core.check |
caps.check(op, **args) |
- | typed | dry-run; explain is the offline CLI twin |
core.describe |
srv.describe(), srv.ops |
- | typed | |
core.health |
srv.core.health() |
- | typed | anonymous; used by readiness |
core.openapi |
srv.core.openapi() |
- | raw | documentation asset; no typed result (asymmetry A1) |
core.reload |
srv.core.reload() |
- | admin | explicit-only admin op; exposed but never in a convenience group (asymmetry A2) |
custom.list |
srv.custom.list() |
list_commands() | typed | |
devtools.code.run |
srv.devtools.code.run(language, code) |
code.run | typed | different engine than port 49999 (asymmetry A4) |
devtools.git.diff |
srv.devtools.git.diff(path) |
- | typed | |
devtools.git.status |
srv.devtools.git.status(path) |
- | typed | |
files.archive |
srv.files.archive(paths) |
- | typed | server-only |
files.delete |
srv.files.delete(path) |
files.remove | typed | |
files.download |
srv.files.download(path) |
files.read_bytes | typed | |
files.download_stream |
srv.files.download_file(remote, local) |
files.download_url (signed) | stream | chunked body |
files.list |
srv.files.list(path) |
files.list | typed | |
files.mkdir |
srv.files.mkdir(path) |
files.make_dir | typed | |
files.move |
srv.files.move(source, destination) |
files.rename | typed | |
files.search |
srv.files.search(root, pattern) |
- | typed | server-only convenience; envd has no search |
files.stat |
srv.files.stat(path), exists() |
files.get_info/exists | typed | |
files.upload |
srv.files.upload(path, data) |
files.write | typed | base64-JSON; large files are better on envd |
files.upload_stream |
srv.files.upload_file(local, remote) |
files.upload_url (signed) | stream | chunked body; envd uses signed URLs |
files.watch |
srv.files.watch(path) |
files.watch | stream | SSE |
process.get |
srv.process.get(pid) |
- | typed | server-only; envd uses a handle |
process.list |
srv.process.list() |
commands.list | typed | |
process.output |
srv.process.output(pid, since) |
- | stream | long-poll; follow() helper |
process.signal |
srv.process.signal(pid, sig) |
commands.kill | typed | explicit-only op |
process.start |
srv.process.start(...) |
commands.start | typed | |
process.stdin |
srv.process.stdin(pid, data) |
commands.send_stdin | typed | |
system.env.get |
srv.system.env.get(names) |
- | typed | |
system.env.set |
srv.system.env.set(vars) |
- | admin | explicit-only; writes the server overlay only |
system.info |
srv.system.info() |
- | typed | envd metrics are platform plane (asymmetry A3) |
system.metrics |
srv.system.metrics() |
metrics (platform) | typed | |
system.packages |
srv.system.packages(manager) |
- | typed | |
system.ports |
srv.system.ports() |
- | typed | |
terminal.connect |
srv.terminal.connect(id) -> PtyConnection |
terminal handle | stream | WebSocket on 9001; policed on upgrade |
terminal.create |
srv.terminal.create() |
get_terminal | typed | explicit-only op |
terminal.delete |
srv.terminal.delete(id) |
terminal handle | typed | |
terminal.get |
srv.terminal.get(id) |
- | typed | server-only |
terminal.list |
srv.terminal.list() |
- | typed | server-only |
terminal.resize |
srv.terminal.resize(id, cols, rows) |
terminal handle | typed | |
custom.<name> |
srv.custom.<name>(**kw) / srv.call('custom.<name>') |
Sandbox.custom(name) | dynamic | tree built from GET /ops (asymmetry A7) |
ext.<handler> |
srv.call('ext.<handler>') |
- | dynamic | arbitrary legacy RouteTable routes; dynamic tree only (asymmetry A7) |
Per-adapter rows are identical for codex, claude_code, qwen_code, cursor, qoder (the table shows codex; the ledger test expands all five).
7. Node CLI (abx) mirror
The CLI (/workspace/cli, TypeScript, ESM) is a first-class consumer:
- Downstream consumer, separate release train (v2.1, IF-28). The CLI is a separate package (
/workspace/cli, its ownpackage.json, vitest, release train). It is fed, not generated in-tree: the SDK release publishesoperations.json(+openapi.json) as a release assetagent-sandbox-server-catalog-<version>.json; the CLI repo'sscripts/sync-catalogdownloads the pinned version, writescatalog.ts/client.tsand records the digest. The SDK CI does not build the CLI; the CLI CI fails on digest drift. - Generated
cli/src/server/generated/catalog.ts(const catalog + types) andclient.ts(ServerClientwith the same namespace tree,fetch-based) fromoperations.jsonthroughagent-sandbox-server gen-client --lang ts(run by a script in the CLI repo against the pinned wheel, or fed the catalog file published as a release assetagent-sandbox-server-catalog-<version>.json).scripts/sync-catalogrecords the catalog digest; CI fails on drift. - Spelling parity: the TypeScript client uses the op IDs and parameter names exactly as spelled on the wire (snake_case:
client.agents.claude_code.run({prompt}),max_entries), no camelCase renaming, so docs, policies and logs read identically in both languages. - Commands (all generic, so new ops need no CLI release):
abx server ops [--json](list effective ops),abx server describe <op>,abx server call <op> [--arg k=v ...|--json '{...}'],abx server check <op> [--arg ...](dry-run policy),abx server doctor(remotecore.capabilities+ health). Friendly wrappers for common namespaces (abx files ls --plane server,abx agents ...per the agents CLI follow-ups) are thin and generated from the catalog where possible. - Error mapping: the CLI exit codes follow the existing table; server error families map as:
auth.*-> auth exit code,policy.*/op.not_enabled-> permission exit code,limit.*/install.*-> retryable code, others -> 1. - The same catalog/OpenAPI makes other languages (Go, Java, ...) a generator invocation away (
operationId= op ID). No hand-maintained clients beyond Python and TypeScript are promised.
8. Versioning and compatibility
- Client sends
X-ABX-Clientwith its catalog version; the server never rejects on it. - Older client / newer server: works (IDs append-only). Newer client / older server: calls to ops the server does not list raise
ContractMismatchErrornaming the serversdk_version, or transparently use the legacy REST binding when the catalog says the op existed in v1 under another route. - v1 servers (no
opsin/capabilities):ServerCapabilities.legacy = True, group-level gating only via golden lists.