Decision: SDK Capability Surface (Capability-Driven, Type-Safe Dynamic)
Status: implemented
Problem
The SDK must expose two kinds of commands: (a) standard capabilities with well-known signatures (commands.run, files.upload, ...) and (b) template-declared custom commands whose names are only known at runtime. We need dynamic dispatch for (b) without sacrificing the static type safety promised by py.typed + mypy.
Decision
Keep standard capabilities as typed methods, and route custom commands through explicit dynamic dispatch — no __getattr__ magic.
- Standard capabilities keep their typed methods (
sandbox.commands.run(...),sandbox.files.upload(...), etc.). Each is subject to capability gating (raisesCapabilityNotSupportedErrorwhen the capability is absent — see capability-model ADR). - Custom commands are invoked via one explicit method:
sandbox.run("name", **args). This is a normal, typed method — arguments are passed through a**kwargsmapping and validated against the template'scustom_commandsschema at call time. We deliberately do not use__getattr__/magic attributes (e.g.sandbox.serve(...)), which would be invisible to mypy and breakpy.typed. - Discovery API:
sandbox.capabilitiesreturns the effective capability set (frozenset[str]);sandbox.list_commands()returns the template's declared custom commands as a list of plain dicts (not objects) — each dict carriesname+description+args. These let users and Agents introspect what a sandbox can do.
API Design
class Sandbox:
@property
def capabilities(self) -> frozenset[Capability]:
"""Effective standard-capability set for this sandbox."""
...
def list_commands(self) -> list[dict[str, Any]]:
"""Template-declared custom commands as a list of plain dicts:
{"name": str, "description": str,
"args": [{"name": str, "required": bool,
"default": str | None, "description": str}]}
"""
...
async def run(self, name: str, **args: str) -> ProcessResult:
"""Dispatch a template-declared custom command by name.
Raises if `name` is unknown or a required arg is missing.
Values are shlex.quote()-escaped before substitution.
"""
...
# Standard, typed, gated as before:
# await sandbox.commands.run("ls -la") # requires "shell"
# await sandbox.files.upload(local, remote) # requires "files"
MCP tools filtered by capabilities = deferred to Phase 2: the MCP Server currently exposes 7 P0 tools that rely only on the default capabilities (shell / files / code) and does not dynamically filter the tool list by
capabilities. When a sandbox has restricted capabilities,dispatch_toolcatchesCapabilityNotSupportedErrorand returns a structured error (rather than crashing or silently omitting the tool). Dynamically filtering the MCP tool set by capabilities is left for Phase 2.
Alternatives considered
__getattr__magic methods (sandbox.serve(port=9000)) — Invisible to static type checkers, breakspy.typed, no autocomplete, surprising failure modes. Rejected; see the capability-model-alternatives rejected ADR.- Only dynamic dispatch (drop typed standard methods) — Loses type safety and discoverability for the common path.
- Codegen typed stubs per template — Too heavy; templates are resolved at runtime.
Dependencies
2026-09-03-capability-model.md(gating on standard methods)2026-09-03-custom-commands-schema.md(schema forrun/list_commands)api/sandbox.py,models/template.py
Test Strategy
sandbox.capabilitiesreflects the effective set (declared vs default baseline).sandbox.list_commands()returns declared custom commands with args.sandbox.run("unknown")raises a clear error;run("serve", port="9000")builds the quoted command.- mypy check:
sandbox.runand standard methods type-check; no reliance on dynamic attributes.
Acceptance criteria
- Standard capabilities remain typed and gated.
- Custom commands are reachable only via
sandbox.run("name", **args). mypy --strictpasses withpy.typedintact (no__getattr__-based dispatch).