SDK API
9 min read
shipit_agent.sdk is shipit's own SDK layer, running on shipit's own agent
loop. It's async: query() streams typed messages for a prompt, AgentClient
keeps a conversation open, and AgentOptions holds the model, tools and
permissions. It runs on every provider shipit supports: Anthropic, OpenAI,
Bedrock (gpt-oss, Gemma 4 and the rest), Gemini, Vertex, and others through
LiteLLM.
import asyncio
from shipit_agent.sdk import AgentOptions, AssistantMessage, ResultMessage, TextBlock, query
async def main() -> None:
options = AgentOptions(
model="claude-opus-5-5",
cwd="/path/to/repo",
allowed_tools=["Read", "Grep", "Bash(pytest *)"],
permission_mode="acceptEdits",
max_budget_usd=2.0,
)
async for message in query(prompt="Fix the failing test in tests/test_api.py", options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(block.text)
elif isinstance(message, ResultMessage):
print(message.subtype, message.num_turns, message.total_cost_usd)
asyncio.run(main())Every option lands on an Agent setting (build_agent(options) returns that
agent), so this is the same loop as Agent.run(), not a second one.
Options
| Option | What it does |
|---|---|
model | A model id. The provider comes from its name: claude-* uses Anthropic, gpt-* and o1/o3/o4 use OpenAI, bedrock/... uses Bedrock, and anything else uses LiteLLM. To use an adapter you built, pass it as llm. |
fallback_model | Used when the model fails after its retries. Takes a model id, ids separated by commas, an adapter, or a list of them. |
system_prompt | A string, or {"type": "preset", "preset": "claude_code", "append": ...}, {"type": "custom", "prompt": ...} or {"type": "file", "path": ...}. |
tools | Omitted: the core set (read, edit, write, multi-edit, notebook edit, bash, glob, grep, web search, fetch, todo, ask, memory). "all": every built-in tool. Or a list of tool objects and names; SDK names such as "Bash" and "Read" work. |
allowed_tools | Rules that pre-approve calls. They don't restrict the other tools. |
disallowed_tools | A bare name removes the tool. A scoped rule ("Bash(rm *)") denies matching calls in every mode. |
permission_mode | default, acceptEdits, plan, dontAsk or bypassPermissions. |
can_use_tool | Decides calls that no rule, mode or hook settled. |
hooks | {"PreToolUse": [HookMatcher(...)], ...}, or an AgentHooks. |
mcp_servers | {name: config}: stdio, sse, http or create_sdk_mcp_server(...). |
agents | Sub-agent definitions (description, prompt, tools, model). |
max_turns, max_budget_usd | The step limit and the spend limit. |
output_format | {"type": "json_schema", "schema": {...}}. The parsed answer is in ResultMessage.structured_output. |
include_partial_messages | Also yield StreamEvent text deltas. |
resume, fork_session, session_store, session_id | Continue a saved session, or a copy of it. |
continue_conversation | Continue the most recently saved session in session_store. |
enable_file_checkpointing | Record files before the file tools change them, for rewind_files. |
cwd, add_dirs | The project root, and folders beside it the file tools may also use. |
env | Environment variables for the commands bash and code execution run, never this process's own. |
sandbox | Run bash and code execution in the OS sandbox, with the SDK's SandboxSettings. See Sandbox. |
effort, thinking, max_thinking_tokens, betas | Passed to adapters that take them (Anthropic does). A thinking budget becomes adaptive thinking on models that reject budgets. |
agent_options | Any other Agent setting, under its shipit name. |
Messages
A run yields these messages:
- A
SystemMessagewith subtypeinit. Itsdataholds the model, tools, MCP servers, permission mode, working directory and session id. - An
AssistantMessagefor each model response. Its content blocks areTextBlock,ThinkingBlockandToolUseBlock. - A
UserMessagefor each tool result, as aToolResultBlockwithis_errorset on failures and denials. - Other
SystemMessages along the way:compact_boundary,api_retryandmodel_fallback. - One
ResultMessageat the end.
The ResultMessage reports how the run went:
- How it ended:
subtypeis one ofsuccess,error_max_turns,error_max_budget_usd,error_max_budget_tokens,error_during_executionorerror_max_structured_output_retries.is_errorsays whether the run stopped short. - Answer:
resultandstructured_output. - Effort and cost:
num_turns,duration_ms,duration_api_ms,total_cost_usdandusage. - Other details:
stop_reasonandpermission_denials.
A run that raises still ends with a ResultMessage, with subtype error_during_execution and the error in errors.
The same fields are on AgentResult when you use Agent.run(). The same model responses arrive there as assistant_message events.
Custom tools
from shipit_agent.sdk import AgentOptions, create_sdk_mcp_server, tool
@tool("add", "Add two numbers", {"a": float, "b": float})
async def add(args):
return {"content": [{"type": "text", "text": str(args["a"] + args["b"])}]}
calc = create_sdk_mcp_server("calc", tools=[add])
options = AgentOptions(mcp_servers={"calc": calc}, allowed_tools=["mcp__calc__add"])- Schema: a full JSON Schema works as well as the
{"name": type}shorthand. - Errors: return
"is_error": Trueto report a failure the model can act on. - Naming: a server's tools are exposed as
<server>__<tool>; rules may name them either way.
External servers use the same mcp_servers dict:
mcp_servers = {
"files": {"type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]},
"docs": {"type": "http", "url": "https://example.com/mcp", "headers": {"Authorization": "Bearer ..."}},
}Permission rules
Rules are checked in the SDK's order:
- Hooks.
- Deny rules.
- Ask rules.
- The permission mode.
- Allow rules.
can_use_tool.
| Rule | Covers |
|---|---|
Bash | every bash call |
Bash(rm *) | bash calls whose command matches rm * as written |
Bash(npm run test:*) | bash commands starting with npm run test |
Edit(src/**) | writes under <cwd>/src, by any file-writing tool |
Edit(//etc/**) | writes under /etc (// is absolute) |
Read(~/.ssh/**) | reads under the home directory's .ssh |
WebFetch(domain:example.com) | fetches from example.com |
mcp__github__* | every tool from the github MCP server |
A compound command is judged part by part. A deny rule matches when any part matches, and an allow rule only when every part does, so Bash(ls *) can't approve ls && rm -rf x. Paths are normalised first, so src/../secrets isn't under src/**.
Approvals
from shipit_agent.sdk import PermissionResultAllow, PermissionResultDeny
async def can_use_tool(tool_name, input_data, context):
if tool_name == "bash" and "deploy" in input_data.get("command", ""):
return PermissionResultDeny(message="No deploys from the agent.")
return PermissionResultAllow(updated_input=input_data)updated_input rewrites the call. An addRules allow in updated_permissions approves this call for the rest of the session.
Calls that need no approval, such as reads and searches, are approved without calling can_use_tool, as in the SDK.
Clarifying questions
When the model asks the user something, can_use_tool is called with tool_name == "AskUserQuestion". The input holds questions, each with question, header, options (a label and a description each) and multiSelect. Answer by echoing the questions with an answers map from question text to the chosen label (a list for multi-select), or with a free-form response:
async def can_use_tool(tool_name, input_data, context):
if tool_name == "AskUserQuestion":
first = input_data["questions"][0]
return PermissionResultAllow(updated_input={
"questions": input_data["questions"],
"answers": {first["question"]: first["options"][0]["label"]},
})
return PermissionResultAllow(updated_input=input_data)A denial, or a run with no can_use_tool, tells the model to state its assumption and carry on.
Streaming input
query() and client.query() also take an async iterable of SDK user messages. Each message runs as a turn of the same conversation:
async def messages():
yield {"type": "user", "message": {"role": "user", "content": "Read app.py"}}
yield {"type": "user", "message": {"role": "user", "content": "Now add tests for it"}}
async for message in query(prompt=messages(), options=options):
...Hooks
from shipit_agent.sdk import HookMatcher
async def no_secrets(input_data, tool_use_id, context):
if ".env" in str(input_data["tool_input"]):
return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "No .env files."}}
return {}
options = AgentOptions(hooks={"PreToolUse": [HookMatcher("Read|Edit", [no_secrets], timeout=5)]})All ten events work: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, SubagentStart, SubagentStop, PreCompact, Notification and PermissionRequest.
- Inputs and outputs: they use the SDK's field names, including
permissionDecision,updatedInput,updatedToolOutput,additionalContextanddecision: "block". - Failures: a hook that raises or times out is skipped, unless its matcher sets
on_failure="block". - Without the SDK layer: the same events exist natively on
AgentHooks, ason_post_tool_use,on_post_tool_failure,on_permission_requestandon_notification.
A conversation
from shipit_agent.sdk import AgentClient, AgentOptions
async with AgentClient(AgentOptions(cwd=".", enable_file_checkpointing=True)) as client:
await client.query("Refactor utils.py")
async for message in client.receive_response():
...
await client.set_permission_mode("acceptEdits")
await client.query("Now update the tests") # the same conversation
turn = [m async for m in client.receive_response()]
await client.rewind_files(turn[0].uuid) # undo that turn's file changesThe client's methods:
interrupt()stops a turn between steps.set_model(model)changes the model from the next call;set_model(None)goes back to the first one.get_context_usage()andget_mcp_status()report on the session.stop_task(task_id)stops a background sub-agent.
Each response starts with a UserMessage echoing the prompt. Its uuid is what rewind_files takes.
Background tasks
Both kinds of background work are tasks: a command started with bash(background=True), or one that outlives its timeout, is local_bash, and sub_agent(background=True) is local_agent. Between steps the runtime checks on them:
- A new task emits a
task_startedevent. - A finished task emits
task_notification. If it completed or failed, the model is also told in the conversation, with the output file or a summary, so it can react without polling.
In the SDK stream these are SystemMessage("task_started") and SystemMessage("task_notification"). task_stop (the SDK's TaskStop, also matched by KillShell and KillBash) stops either kind by id, and so does AgentClient.stop_task(task_id). A tool can join in by defining list_background_tasks() and stop_background_task(task_id) (see shipit_agent.core.tasks).
Deferring a call for later
A PreToolUse hook that answers permissionDecision: "defer" (or a before-tool hook that returns {"decision": "defer"}) pauses the run after that step:
- When it pauses: the call doesn't run. The run ends with
stop_reason == "tool_deferred", anddeferred_tool_useholds the call'sid,nameandinput. The call is saved with the session. - When you resume: resume the session with the same
session_idandsession_store, orAgentOptions(resume=...). Before the model is called, the hook is asked again. An approval runs the call and puts its result where the call stood; a refusal is recorded there instead; another defer pauses again without calling the model.
Precedence among hook answers is deny, then defer, then ask, then allow. Use this when a person may take longer to decide than the process should stay open. For approvals within the run, use can_use_tool. To queue side effects and keep going, use ApprovalQueue.
Plugins and settings files
options = AgentOptions(
cwd="/path/to/repo",
plugins=[{"type": "local", "path": "./plugins/deploy-tools"}],
setting_sources=["project", "local"],
)A plugin is a directory in this layout:
deploy-tools/
├── .claude-plugin/plugin.json # optional manifest
├── skills/<name>/SKILL.md # skill deploy-tools:<name>
├── commands/<name>.md # command /deploy-tools:<name>
├── agents/<name>.md # sub-agent deploy-tools:<name>
├── hooks/hooks.json # command hooks
└── .mcp.json # MCP serversManifest keys: skills adds to skills/, commands and agents replace their folders, and hooks and mcpServers merge with the default files. ${CLAUDE_PLUGIN_ROOT} is filled in in commands, agents and MCP configs, and hook commands get it in their environment. A plugin that doesn't load is reported in the init message's plugin_errors, as is a path that leaves the plugin directory. The init message also lists plugins, skills and slash_commands.
setting_sources reads ~/.claude/settings.json (user), .claude/settings.json (project) and .claude/settings.local.json (local):
- Permissions: the
allow,denyandasklists add up across files, anddefaultModecomes from the most specific file. - Model and env:
modelis used when no model is given.envgoes to hook commands only, never this process's environment. - Hooks: every file's hooks run.
- Commands:
.claude/commands/*.mdload withproject.
Command hooks get the hook input as JSON on stdin.
- Exit 0: stdout, if it's one JSON object, is the hook's output. Plain text is added as context for
UserPromptSubmit. - Exit 2: blocks, with stderr as the reason.
- Other exits and timeouts: logged; the run carries on.
Commands run with a minimal environment, so this process's secrets don't leak into them, plus CLAUDE_PROJECT_DIR and CLAUDE_PLUGIN_ROOT. Hook events and hook types shipit can't run, such as SessionStart and prompt hooks, are skipped and reported.
The same works without the SDK layer: Agent(llm=llm, harness=Harness.load(cwd, plugins=[...], setting_sources=[...])), and Agent.run("/deploy-tools:status staging") expands the command.
Saved sessions
from shipit_agent.sdk import fork_session, list_sessions, rename_session
from shipit_agent.stores.session import FileSessionStore
store = FileSessionStore(".shipit/sessions")
for info in list_sessions(store):
print(info.session_id, info.summary)
rename_session(store, session_id, "Release plan")
branch = fork_session(store, session_id)
options = AgentOptions(session_store=store, resume=branch)Sandbox
sandbox runs bash and run_code inside the operating system's sandbox, with the SDK's SandboxSettings: writes only in the project, the network off unless allowed, and no approval needed for sandboxed commands. See Sandbox for every setting and its limits.
Good to know
- Settings files are opt-in. shipit reads none unless
setting_sourcesnames them, because they can run hook commands and a library has no "do you trust this folder?" prompt. Plugins run because you name each one. - No CLI process. shipit runs in your process; there is no subprocess to configure. Project files (
CLAUDE.md,.claude/agents,.shipit/skills) are read directly. - Sessions live in a
SessionStore, not in transcript files. The agent stampsupdated_aton each save;continue_conversationpicks the latest. continue_: Falsein a hook output refuses that call or prompt; it doesn't end the whole session.PreToolUseandPermissionRequestcallbacks gettool_use_id=None.interrupt=Truefromcan_use_toolrefuses the call; it doesn't end the session.sandboxfilters the network per machine, not per domain. Anynetwork.allowedDomainsentry turns the network on for every host, anddeniedDomainsis not enforced. See Sandbox.- File checkpoints cover the file tools, not files changed by shell commands.