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.
| option | default | meaning |
|---|---|---|
model, thinking | the built-in default | as --model, --thinking |
mode | manual | manual, auto or plan; governs built-in tools, not yours, except that plan asks for every client tool |
sandbox | on | on, off or external; see the sandbox |
builtin_tools | the mode’s list, less skill unless skills | the 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) | none | tools your program runs; at most 128 |
system_prompt | harness’s | SystemPrompt::Replace(text) or SystemPrompt::Append(text) |
project_instructions | off | read the project’s AGENTS.md or CLAUDE.md |
user_config | off | read your own setup: config.toml, $CONFIG/AGENTS.md, the saved model, the account |
skills | off | discover skills (the project’s; personal ones too with user_config) |
agents | off | discover agent definitions (same roots rule) |
hooks | off | run hooks from config; refused without user_config |
mcp_server(name, json) | none | an MCP server for this session, in the [mcp.servers] shape. To be callable, builtin_tools must include mcp |
approval_timeout | 15 min | how long an approval request waits; at most 24 h, or None for ever |
allow_escapes | off | as --allow-escapes |
max_iterations | the built-in default | as --max-iterations |
cwd, env | the program’s | the process’s working folder, and variables added to its environment |
binary | none | use this harness binary |
mock | none | answer from a mock file, see Testing |
on_approval | none | see 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 skips | unless |
|---|---|
config.toml: providers, [models] nicknames, [mcp.servers], [[hooks]], [away] | user_config |
$CONFIG/AGENTS.md, the saved model choice, the account and its providers | user_config |
the project’s AGENTS.md / CLAUDE.md | project_instructions |
skills under the project’s .agents | skills |
skills under $CONFIG, $AGENTS and [skills] extra_roots | skills and user_config |
agent definitions under the project’s .agents | agents |
agent definitions under $CONFIG and $AGENTS | agents and user_config |
| hooks | hooks 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.