Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The agent SDK

megacode-agent-sdk starts a harness session from your program, hands the model tools you wrote, and streams back what it does. Use it to build an agent that is not a coding agent: a text-message assistant, a support bot, a nightly job. One session is one harness process; the SDK speaks the conversation over its pipes and hides the lines.

Rust shipped in 0.30.0; Python and TypeScript ship in 0.31.0, with the same shapes under the same name. Examples below show Rust first, then Python and TypeScript. Python names are snake_case and its calls are awaited in asyncio; TypeScript names are camelCase and its calls return promises. Where a section shows one language, the others work the same way unless it says otherwise.

An errand

Pip is a text-message assistant. Dana texts “book me a train to Leeds on Friday”. Pip’s server starts one session for the errand, gives the model two tools written in the program (search_trains, and pay, which asks first), and puts the model’s question to Dana as a text. The model never sees a shell, a file or anything else of the machine.

#![allow(unused)]
fn main() {
use megacode_agent_sdk::{Client, Decision, Options, Tool, ToolContext, ToolOutput};
use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct Search { to: String, day: String }

let search = Tool::new(
    "search_trains",
    "Find trains to a destination on a day.",
    json!({"type": "object",
           "properties": {"to": {"type": "string"}, "day": {"type": "string"}},
           "required": ["to", "day"]}),
    |args: Search, _ctx: ToolContext| async move {
        Ok::<_, String>(ToolOutput::text(format!("{} to {}: 9:10 for 42 pounds", args.day, args.to)))
    },
);

let options = Options::plain("You are Pip, a personal assistant that runs errands for Dana. Be brief.")
    .tool(search)
    .on_approval(|request| async move {
        // Text Dana here, wait for her reply, and answer.
        Decision::AllowOnce
    });

let client = Client::start(options).await?;
let mut turn = client.send("book me a train to Leeds on Friday").await?;
while let Some(event) = turn.next().await {
    println!("{:?}", event?);
}
client.close().await;
}
import asyncio
from megacode_agent_sdk import AllowOnce, Client, Options, tool

@tool(
    "search_trains",
    "Find trains to a destination on a day.",
    {"type": "object",
     "properties": {"to": {"type": "string"}, "day": {"type": "string"}},
     "required": ["to", "day"]},
)
async def search(args, ctx):
    return f"{args['day']} to {args['to']}: 9:10 for 42 pounds"

async def approve(request):
    # Text Dana here, wait for her reply, and answer.
    return AllowOnce()

async def main():
    options = Options.plain(
        "You are Pip, a personal assistant that runs errands for Dana. Be brief.",
        tools=[search], on_approval=approve,
    )
    async with await Client.start(options) as client:
        turn = await client.send("book me a train to Leeds on Friday")
        async for event in turn:
            print(event)

asyncio.run(main())
import { Client, plain, type Tool } from "megacode-agent-sdk";

const search: Tool = {
  name: "search_trains",
  description: "Find trains to a destination on a day.",
  inputSchema: {
    type: "object",
    properties: { to: { type: "string" }, day: { type: "string" } },
    required: ["to", "day"],
  },
  handler: async (args) => {
    const { to, day } = args as { to: string; day: string };
    return `${day} to ${to}: 9:10 for 42 pounds`;
  },
};

const options = plain("You are Pip, a personal assistant that runs errands for Dana. Be brief.", {
  tools: [search],
  // Text Dana here, wait for her reply, and answer.
  onApproval: async () => ({ kind: "allow_once" }),
});

const client = await Client.start(options);
try {
  const turn = await client.send("book me a train to Leeds on Friday");
  for await (const event of turn) console.log(event);
} finally {
  await client.close();
}

The whole Rust errand, runnable with no key, is sdk/rust/examples/pip.rs with its mock file pip.mock.toml. The Python and TypeScript examples are sdk/python/examples/ticket_fix.py and sdk/typescript/examples/minimal.ts, each with a mock file beside it. Harness takes the nearest folder at or above cwd that holds a .git as the project, so run an example in a folder outside any repository, or give it a cwd that is a repository of its own.

Install

cargo add megacode-agent-sdk

The crate is megacode-agent-sdk on crates.io; its API reference is on docs.rs.

pip install megacode-agent-sdk
npm install megacode-agent-sdk

megacode-agent-sdk on PyPI needs Python 3.10 or newer, ships as a wheel only (no source build), and uses the standard library alone. A python.org Python on macOS needs its Install Certificates.command run once before the download’s https works. megacode-agent-sdk on npm needs Node 20 or newer and is an ES module (import, not require); it has no runtime dependencies.

Each package needs a harness binary of the same major and minor version. It finds one in this order: the binary option; the MEGACODE_AGENT_SDK_BINARY environment variable; a verified copy in its cache; a download. See Getting the binary.

Two ways in

query(prompt, options) sends one prompt and yields the session’s events up to that turn’s result, then ends the session. Client::start(options) keeps the session open: send(prompt) returns one turn’s event stream, and steer, abort, compact, away, state and respond work on a shared, cloneable handle while a stream is read. Client::resume(session_id, options) continues a stored session.

A turn belongs to the session from send until its result, even if you stop reading the stream: a second send before then fails with TurnInProgress. abort() names that turn’s prompt: a running turn is interrupted (Interrupting); a prompt still queued (behind a compaction) is taken back (Dequeued), never runs, and its stream ends with no result, so the next send may follow. The stream starts at the prompt’s user line. Events that arrive between turns, or in a turn the prompt did not open (one a background agent’s result opens), go to session_events(), which you may take once. If you leave more than 10,000 events of a turn unread, the session ends with SlowConsumer.

close() sends quit, gives the process 5 s to exit, then SIGTERM, then SIGKILL, and returns within about 15 s. Dropping the client is a last-resort kill, not a clean end.

In Python the names are Client.start(options), Client.resume(session_id, options) and query(prompt, options) (an async generator); aclose() ends the session, and async with await Client.start(...) does it for you. A turn’s stream is an async iterator: async for over it, or break to stop reading, which leaves the turn running; then await turn.aclose() if turn stays in scope, or its unread events still count toward the 10,000 limit. In TypeScript they are Client.start, Client.resume and query; sessionEvents() is the between-turns stream and close() ends the session.

TypeScript: always await client.close(), or declare the client with await using (which needs Node 20.4.0 or newer for Symbol.asyncDispose). A client left open keeps Node’s event loop alive, and the child is killed only when the process exits. To drop a stream, break out of the for await or call its return(); the turn still runs on until its result.

A Python handler or callback that raises SystemExit or KeyboardInterrupt ends the program, as asyncio does for any task.

Options

Options::plain(system_prompt) is the setup for an agent that is not a coding agent: no built-in tools, your prompt in place of harness’s own, and nothing read from the machine. Options::new() (Options() in Python, {} in TypeScript) leaves every field at its default, which is also isolated. The SDK writes the options to a private temporary file and removes it as soon as the session has started.

optiondefaultmeaning
model, thinkingthe built-in defaultas --model, --thinking
modemanualmanual, auto or plan; governs built-in tools, not yours, except that plan asks for every client tool
sandboxonon, off or external; see the sandbox
builtin_toolsthe mode’s list, less skill unless skillsthe most built-in tools the session ever offers, for its whole life; [] offers none. The ceiling covers mcp, script and agent too
tool (client tools)nonetools your program runs; at most 128
system_promptharness’sSystemPrompt::Replace(text) or SystemPrompt::Append(text)
project_instructionsoffread the project’s AGENTS.md or CLAUDE.md
user_configoffread your own setup: config.toml, $CONFIG/AGENTS.md, the saved model, the account
skillsoffdiscover skills (the project’s; personal ones too with user_config)
agentsoffdiscover agent definitions (same roots rule)
hooksoffrun hooks from config; refused without user_config
mcp_server(name, json)nonean MCP server for this session, in the [mcp.servers] shape. To be callable, builtin_tools must include mcp
approval_timeout15 minhow long an approval request waits; at most 24 h, or None for ever
allow_escapesoffas --allow-escapes
max_iterationsthe built-in defaultas --max-iterations
cwd, envthe program’sthe process’s working folder, and variables added to its environment
binarynoneuse this harness binary
mocknoneanswer from a mock file, see Testing
on_approvalnonesee Approvals

An unknown field, a wrong type, JSON null or an invalid value is refused before anything is written; the error names the field.

What isolation skips

Every personal read is off unless you turn it on. Sessions are still written, and found, where they always are. The model catalog is still read: it is public data.

harness skipsunless
config.toml: providers, [models] nicknames, [mcp.servers], [[hooks]], [away]user_config
$CONFIG/AGENTS.md, the saved model choice, the account and its providersuser_config
the project’s AGENTS.md / CLAUDE.mdproject_instructions
skills under the project’s .agentsskills
skills under $CONFIG, $AGENTS and [skills] extra_rootsskills and user_config
agent definitions under the project’s .agentsagents
agent definitions under $CONFIG and $AGENTSagents and user_config
hookshooks and user_config

Provider keys come from the environment, as always. With user_config off only the built-in providers are known; a program that needs a custom provider turns it on. Sub-agents inherit the isolation. They do not inherit your system_prompt: a child gets harness’s default prompt, assembled under the same isolation.

A server running many errands leaves one log per session in the sessions folder. To keep them elsewhere, set HARNESS_HOME with env; retention is yours.

The system prompt

Replace(text) makes text the whole prompt plus the blocks for the capabilities you enabled; no harness prompt, instruction files or listing. Append(text) puts text after harness’s prompt and listing. Neither is truncated.

Python spells the options as Options fields: Options(mode="auto", builtin_tools=[...]), Options.plain(prompt, **fields), with Replace(text) and Append(text) for the system prompt, mcp_servers={name: config} for MCP, tools=[...] for client tools, and approval_timeout in seconds (None for ever). TypeScript spells them as an object: { mode: "auto", builtinTools: [...] }, plain(prompt, more), systemPrompt: { replace } | { append }, mcpServers, tools, and approvalTimeoutMs in milliseconds (null for ever), rounded up to whole seconds on the wire.

Tools

A tool is a name, a description, a JSON schema for its input, an async handler, ask (default false) and timeout (default 5 min, at most 24 h). The handler gets the arguments as your type and a ToolContext with the call id and a cancellation signal, and returns ToolOutput::text(..), or ToolOutput::with_image(..) for text plus one image (PNG, JPEG, GIF or WebP). An Err becomes an error result the model reads.

In Python, @tool(name, description, schema, ask=False, timeout=300.0) wraps an async def handler(args, ctx) (args a dict, ctx.call_id, ctx.cancelled an asyncio.Event) that returns a str or a ToolOutput.text(..) / ToolOutput.with_image(text, media_type, data); a plain def is refused at @tool. In TypeScript a tool is { name, description, inputSchema, ask?, timeoutMs?, handler }, with handler(args, ctx) returning a promise of a string or { text, image?: { mediaType, data } }, and ctx.callId, ctx.signal an AbortSignal. Both receive the arguments as parsed JSON, not as a typed value: the schema check below is the only gate, so a handler reads what it needs and guards the rest.

Before your handler runs, the SDK checks the arguments against the schema’s top-level required, each property’s type and additionalProperties: false. An invalid call, an argument that does not deserialize or a handler that panics (in Python or TypeScript, raises, throws or rejects) answers the model with an error; your program does not crash. The cancellation signal fires when harness cancels the call (the turn is aborted, the call times out, stdin closes), when the process exits and on close(); a result sent after it fires is dropped. A result over about 8 MiB is replaced by an error saying so.

Calls from one model response run one after another. Sub-agents, /btw and script are offered none: a sub-agent cannot call your tools, and a tool named in your list cannot be called from a script. The tool list is fixed when the session starts, and a resume must give the same list. A tool’s name is ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$, unique, and not a built-in’s.

ask(true) makes every call an approval request of kind: "client_tool", answerable once (allow_once, deny, deny_and_stop; no turn grant). In plan mode every client tool asks. A client tool is never judged and never covered by an earlier grant.

Resumed in the panel or the REPL, a session with client tools still offers them; each call ends with the error “no program is attached to run <name>”. The same error ends a call made after your program’s stdin has closed. If writing to your program fails, calls end with “the program’s connection failed”, and a call that is not answered in time ends “timed out after <t>”.

Approvals

An approval request is a gate: a built-in tool in manual mode, a client tool with ask, an escape from the sandbox. With on_approval, your callback gets each request and returns a Decision (AllowOnce, AllowForTurn, Deny { reason }, DenyAndStop), which must be one of the request’s choices. A callback that panics, or returns a choice not offered, answers Deny with the failure as the reason. The request is not yielded as an event; the approval line that follows is.

Without a callback the request is yielded as an ApprovalRequest event. Answer it with its own respond(decision), or from anywhere with Client::respond(request_id, decision).

In Python the callback is on_approval=async def f(request) returning AllowOnce(), AllowForTurn(), Deny(reason) or DenyAndStop(); in TypeScript onApproval: async (request) => ({ kind: "allow_once" }), with allow_for_turn, { kind: "deny", reason } and deny_and_stop the others. await request.respond(decision) and await client.respond(id, decision) answer a yielded request.

An unanswered request times out after approval_timeout; the SDK never answers for you. The timeout does what the panel’s does: where away is allowed (approvals required, the sandbox on or external, and a mode that does not ask for every command), the session goes away; otherwise the turn ends. Pip’s setup (builtin_tools: [], manual) allows away, so a timed-out pay is refused, and every later call that asks is refused with no request, until your program calls away(false) or the turn ends. To get a decision on every call whether or not anyone answers, answer in on_approval.

A full escape from the sandbox is never asked of a program unless allow_escapes is on, because the program may be a model with nobody behind it. Resuming a session recorded as ask needs allow_escapes(true), and the flag on a session recorded otherwise is refused, naming --allow-escapes.

Testing without a model

Options::mock(path) answers the model from a mock file: its replies, tool calls and judge assessments scripted in TOML, as harness run --mock reads them. No key, no network. It is a live conversation otherwise: the mode, the sandbox, your client tools, approvals and the log are all real. See sdk/rust/examples/pip.mock.toml for the format.

[[reply]]
text = "Let me look at Friday's trains."
[[reply.tool_calls]]
tool = "search_trains"
args = { to = "Leeds", day = "Friday" }
options = Options.plain("You are Pip.", tools=[search], mock="pip.mock.toml", binary="/path/to/harness")
const options = plain("You are Pip.", { tools: [search], mock: "pip.mock.toml", binary: "/path/to/harness" });

A sub-agent call in a mock conversation ends with the error “sub-agents are not available in a mock conversation”, so a test can never reach a provider. A resumed mock session answers from the start of the file you give it; tool call ids continue from the log.

Replay, and what it does not undo

Client::id() names the session; the session id is on every result too. Three commands work on an SDK session today:

harness session <id> --transcript   # the card, or the scrollback
harness replay <id>                 # re-run the log with no model and no program; checks it reproduces
harness export <id>                 # Markdown, HTML or JSONL

Your tools’ calls and results are in the journal, so a replay needs neither the model nor your program: it serves the recorded result. Cases built from SDK sessions, and flagging a session from the program, are a later slice.

What the journal does not do:

  • A client tool runs in your program, outside harness’s sandbox, judge and modes.
  • Cancelling a call does not undo it.
  • A replay serves the recorded result and never calls your program again.
  • A crash during a call can leave the action done (a payment made) with no result recorded. Recovery reports such a call as unknown, not as failed.

Resuming

Client::resume(session_id, options) sends only what a resume may change: model, thinking, max_iterations, approval_timeout, allow_escapes, and your client tools. For a session started with the SDK, the mode, builtin_tools, system prompt, isolation switches and sandbox come from the log, and giving them is refused. Client tools must equal the log’s, name for name and schema for schema; none given when the log has some is refused. MCP servers come from the recorded baseline, never from config.toml, so Client::resume never sends the options’ MCP servers. Refused fields exit with status 2 and name the field.

client = await Client.resume(session_id, Options(tools=[search], max_iterations=40))
const client = await Client.resume(sessionId, { tools: [search], maxIterations: 40 });

A session not started with the SDK can be resumed with the five changeable fields, and nothing else: an existing session is never turned into an isolated one. A resume in conversation form needs the session id.

Versions

The wire carries v: 1 and harness_version, and only ever gains lines, commands and fields. An SDK of version X.Y.Z (Rust, Python or TypeScript) requires a harness of X.Y.* and raises Handshake (HandshakeError in Python and TypeScript) otherwise; a binary it downloaded always matches. The wire is written down in protocol/stream-json.schema.json.

Getting the binary

In order: the binary option, the MEGACODE_AGENT_SDK_BINARY environment variable, the verified cache, a download. The first two are trusted as given (the version check still applies).

The download is of v<version>/harness-<target>.tar.gz from the release site, over https only, checked against a SHA-256 digest compiled into the package (of the archive, and again of the executable inside). The pins were verified against the release’s signatures when the package was built, and the crate, the wheel and the npm package carry the same three. Only x86_64-unknown-linux-musl, aarch64-apple-darwin and x86_64-apple-darwin exist; any other platform (Linux on ARM, Windows) gets an error naming the three and the binary option. The error text calls it Options::binary in all three languages. MEGACODE_AGENT_SDK_DOWNLOAD_BASE points the download elsewhere, for a mirror.

The cache is <cache>/megacode-agent-sdk/<version>/<target>/harness, with <cache> $XDG_CACHE_HOME or ~/.cache (Linux), ~/Library/Caches (macOS). Every start hashes the file before running it, and a wrong digest is replaced by a verified download, never run. The SDK refuses a cache path or file that another user could change, naming the folder; there is no fallback, so set binary.

The Python download’s 120 s bound covers the DNS lookup too: the wait ends at the deadline, though the resolver’s thread may finish later, its answer unused. The TypeScript download follows HTTPS_PROXY and NO_PROXY only on Node 22.21 or newer, or 24.5 or newer (its proxyEnv setting); older Node connects directly, so behind a mandatory proxy name binary.

Without the default download feature the Rust crate never touches the network: the binary comes from binary or the environment variable only.

The trust note

The options file has the authority of a command line. It can turn the sandbox off, allow escapes and launch MCP servers, as flags and config.toml can, and it is not checked against the rule that refuses [sandbox], [mcp.servers] and [[hooks]] in a config found inside a project. That is because your program, not a repository, wrote it. Never build it from model output or from anything a user types.

Your tools are your responsibility: harness cannot sandbox what runs in your process, and a tool that pays is only as safe as the schema, the ask flag and the code behind it.