@gomomento-poc/mo-sdk
v0.4.0
Published
TypeScript SDK for mo's session protocol (mo-session v1) — spawn a mo session-host, drive turns, and intercept tool calls. Thin by rule: never agent logic.
Maintainers
Readme
@gomomento-poc/mo-sdk
The programmable surface for mo, Momento's coding agent, over the
mo-session protocol. Spawn a mo session-host process, drive turns, and intercept every tool call
it makes.
Thin by rule. The SDK owns the wire — framing, request correlation, and the always-answer
discipline every host round-trip depends on — and nothing else. Agent behaviour lives in mo, so an
embedder and the CLI cannot drift apart.
Requirements
- Node ≥ 20 — the version CI validates the package against. The
await usingform in the examples additionally needs a runtime that providesSymbol.asyncDispose; where it is missing, callclose()instead and everything else is unchanged. - The
mobinary onPATH, or passbinarytocreateAgentSession - Gateway credentials in the environment, exactly as the CLI reads them
Install
npm install @gomomento-poc/mo-sdkQuickstart
import { createAgentSession } from "@gomomento-poc/mo-sdk";
await using session = await createAgentSession({
model: "momento/zai-org/GLM-5.2",
cwd: process.cwd(),
context: { preamble: "You are a release-notes assistant. Be terse." },
maxIterations: 20,
compactionThresholdPercent: 90,
});
session.on("assistant", ({ text }) => process.stdout.write(text));
const turn = await session.prompt("Summarise the last five commits.");
console.log(turn.stop_reason, turn.usage);
const result = await session.close();
console.log(result.outcome, session.sessionId);await using closes the session at scope exit; using kills it outright. Without either, call
close() (clean, resolves on the terminal result) or kill().
session.sessionId is the durable resume target. The host owns the journal; the SDK deliberately
does not expose its filesystem path.
What you can control
| Capability | Surface |
|---|---|
| System prompt | context: { preamble } — see Context sections |
| Model & effort | model, strongModel, models, effort |
| Sampling | providerParams: { temperature, max_tokens } |
| Context budget | compactionThresholdPercent — a percent of the ROUTE's window, resolved host-side |
| Iteration cap | maxIterations |
| Tool allow-list | allowTools |
| Static permissions | permissions: { allow, deny } — rule-level, evaluated before any prompt |
| Dynamic approval | onApproval(request) → { choiceId } or { choiceId: "deny", denyReason } |
| Custom tools | tools: [{ name, description, inputSchema, execute }] |
| Rewriting a call | onToolCallMutate({ tool, tool_call_id, arguments }) → the new arguments |
| Rewriting a result | onToolResultRewrite({ tool, tool_call_id, result }) → the new result string |
| Cost attribution | taskId |
| Durability & resume | session: { mode, resume } |
| Peer plane | intercom: { name, mode, groups, transport } — see Intercom |
| A presence, no model | openIntercom({ name }) — see A presence on the plane |
| Plane readiness | peerReadyTimeoutMs — the arming budget, from the configure reply |
| Startup fan-out | configureSdk({ spawnConcurrency }) — process-wide, see Lifecycle |
| Lifecycle | session.on(kind, handler) — see Events |
Context sections
context overrides mo's own assembled prompt per section: a string replaces the named
section, an explicit null suppresses it, and an absent key leaves the host's assembly alone. The
sections are preamble, agents_md, memory, repo_map, skills; the host rejects any other
name.
context: {
preamble: "You are the TRIAGE role. …", // replace
memory: null, // suppress
} // agents_md, repo_map, skills: host's ownIt is per-configure, not persisted — a resume that wants the same overrides passes them again.
Custom tools
execute returns a ToolResult or a bare string. A rejection fails the call closed as a
model-visible error.
tools: [
{
name: "emit_decision",
description: "Report the decision. Call exactly once.",
inputSchema: { type: "object", properties: { kind: { type: "string" } }, required: ["kind"] },
terminal: true, // this call ends the session as `completed` (structured-output shutdown)
execute: async (args, { toolCallId }) => {
record(args);
return { content: "recorded" };
},
},
]ToolResult also carries details (echoed on the tool_result frame, never model-visible),
isError, and pause — which completes the call and then ends the session as paused, the
pause-for-human disposition. Model-visible content is capped at 256 KiB by the host.
The two interception hooks
Both are async, and both return the replacement value itself — not a wrapper object.
Returning undefined means "leave it unchanged".
onToolCallMutate: async ({ tool, arguments: args }) => {
if (tool !== "read_file") return undefined; // unchanged
return { ...(args as Record<string, unknown>), path: SAFE }; // the new arguments
},
toolResultRewritePolicy: "required",
onToolResultRewrite: async ({ result }) => redactSecrets(result),onToolCallMutate runs before validation, classification and approval, so policy judges — and
any approval prompt displays — the arguments that will actually execute. A registered hook that does
not answer declines that call: an unexamined call must not run when you asked to examine calls.
onToolResultRewrite runs at the single chokepoint the rendered output, model context, and durable
journal all funnel through, so a redaction cannot land in one and miss another. Its
failure direction is toolResultRewritePolicy, and setting the handler without it is an error:
best_effort— a hook that fails leaves the original result standing; the turn continues.required— a hook that fails withholds the result and ends the session aserror.
Events
session.on(kind, handler) returns an unsubscribe. The kinds:
session_start, user, assistant, reasoning, tool_call, tool_result, iteration_end,
phase, notice, approval_declined, session_shutdown, peer_ready, peer_message,
peer_turn_started, peer_turn_complete, result, mo/error.
There is no text or turn_end kind — model output arrives as assistant, and turn completion is
the resolved value of prompt(), not an event. session_start is a one-shot opening fact emitted
while the session is still being created, so a subscriber added afterwards still receives it.
A handler that throws — or an async one that rejects — is contained: the remaining subscribers for
that event still run, and the failure is reported as an mo/error with code subscriber_failed.
Subscribe to mo/error if you want to see your own handler bugs — with one gap by construction, a
failing mo/error handler, whose failure is dropped rather than routed back through itself.
Intercom
A session can join the peer plane, so other mo sessions under the same login address it by name.
Two things happen at open: the SDK declares the capability at initialize, and arms the plane at
configure. createAgentSession does not resolve until the host reports the plane live, so a
session you hold is a session peers can reach.
import { createAgentSession } from "@gomomento-poc/mo-sdk";
await using session = await createAgentSession({
model: "momento/zai-org/GLM-5.2",
intercom: { name: "planner" },
});
console.log(session.intercom?.peerId); // the id peers address
session.on("peer_message", ({ handle, sender_name, text, attested }) => {
console.log(`${sender_name ?? "unknown"}: ${text}`);
});
const { messageId } = await session.intercom!.send("scout", "claim WR Smith");
const peers = await session.intercom!.peers(); // [{ sessionId, name?, departed, paired }]name is what peers see: printable ASCII, no spaces, at most 64 characters, and never beginning
with #. That last one is not arbitrary — a send target written #<name> is always a group, and a
roster offers names as targets, so a session calling itself #ops would put a target on every
peer's roster that publishes to the group ops instead of reaching it. The SDK refuses all of that
before opening.
Sending. target is a peer's announced name, its exact session id, or an id prefix of at least
six characters. A target that is not exactly one live peer is refused rather than guessed at, and
that refusal arrives as a PeerUnresolvedError whose candidates lists what the host would have
had to choose between (empty when the target was simply unknown or departed). replyTo takes the
handle of a message you received, and then target must be its sender.
import { createAgentSession, PeerUnresolvedError } from "@gomomento-poc/mo-sdk";
const session = await createAgentSession({ model: "momento/zai-org/GLM-5.2", intercom: { name: "planner" } });
try {
await session.intercom!.send("sc", "hello");
} catch (error) {
if (error instanceof PeerUnresolvedError) console.log(error.candidates); // ["scout", "scribe"]
}Two vocabularies meet here, as everywhere in this SDK: what you pass in is camelCase
(replyTo), and what the host hands you keeps the wire's own names. So peers() reads
sessionId — the SDK built that list — while the peer_message event reads sender_name, because
that payload is the host's, delivered as it arrived.
Hearing. peer_message is observational: the SDK hands you the message and inserts nothing
into the model on its own account. Everything on it except attested is the sender's own claim,
including senderName — treat the text as content from a process nobody authenticated.
Opening. Everything the host emits between peer_ready and the moment createAgentSession
returns is held for you, and delivered to the first subscriber for that kind. A message that
arrives in that window has no subscriber it could have reached, and for a receive-only session
hearing is the whole job. The plane gets its own peerReadyTimeoutMs (default 30s, the host's own
deadline) measured from the configure reply, so a slow handshake does not eat the arming budget.
Receive-only. A session that only listens may be opened without a model, and the host refuses
prompt on it. It is the one session kind where model is optional.
import { createAgentSession } from "@gomomento-poc/mo-sdk";
await using listener = await createAgentSession({
intercom: { name: "scoreboard", mode: "receive_only" },
});Admission. intercom.onMessage decides whether one inbound message reaches the model, and the
host asks per message within a five-second window.
import { createAgentSession } from "@gomomento-poc/mo-sdk";
const session = await createAgentSession({
model: "momento/zai-org/GLM-5.2",
intercom: {
name: "planner",
onMessage: async ({ sender_id, attested, text }) => (attested && known(sender_id) ? "admit" : "refuse"),
},
});It fails closed in every direction. No handler refuses, a handler that throws refuses, and one that outruns the window refuses, because the host has already decided by then. A session that declared it would decide and then did not has consented to nothing.
The handler and the peer_message event are different things, and the difference is easy to miss.
The event is observational and fires for every arrival, including one a standing refusal already
dropped. The handler is the blocking request that gates the model. Subscribe to the event without
registering the handler and you will see every message and admit none of them.
The answer is a string rather than a boolean because the set is expected to grow, and a host that meets a decision it does not know refuses that message rather than guessing.
Lifecycle
createAgentSession hands back a session that is ready to use. It resolves after the
handshake, and — when intercom is declared — after the host reports the peer plane live. It
rejects rather than handing back a half-open session: a host on another protocol version, a
rejected configure, a plane that cannot be armed, or a host that dies mid-handshake. A rejected
open leaves no child process behind.
Two deadlines bound it, and they are separate because the host counts them separately.
handshakeTimeoutMs (default 30s) covers initialize and configure; peerReadyTimeoutMs
(default 30s) starts at the configure reply and covers arming, which is when the host starts its
own clock. A slow handshake therefore does not eat the arming budget.
close() sends shutdown and waits. It owns no ordering of its own: the host stops admission,
settles what it owes, tears down the plane, and answers — close() resolves on the terminal
result that follows. It resolves immediately if the session already ended on its own (a pause
disposition, a terminal tool), because there is nothing left to shut down.
How long it waits for that answer is closeGraceMs. The default depends on what the host has to
do: 5s for a session with no peer plane, and 20s for an armed one, whose shutdown is a whole
ordered sequence the host bounds at 15 seconds. Killing at 5 would preempt a host doing exactly
what it promised.
A shutdown is legal only between turns, so a host that is not between turns refuses it and the
SDK raises SessionBusyError. The refusal is not a death: the session is still usable and its host
was not killed. The error's message is the host's own and says which case it is — a turn running,
or a close already under way.
interrupt() is a notification, so it returns before the turn has actually ended. A retry that
follows it immediately can meet the same refusal; wait for the turn you interrupted to settle
first, which is the prompt() promise you are already holding.
import { createAgentSession, SessionBusyError } from "@gomomento-poc/mo-sdk";
const session = await createAgentSession({ model: "momento/zai-org/GLM-5.2" });
const turn = session.prompt("Summarise the last five commits.");
try {
await session.close();
} catch (error) {
if (!(error instanceof SessionBusyError)) throw error;
session.interrupt();
await turn.catch(() => undefined); // let the turn end before asking again
await session.close();
}Calling close() twice does not send a second shutdown: the second call joins the first. A close
that was refused stays retryable, which is what makes the sequence above work.
A forced end is not a failure. If the host's ordered shutdown runs past its budget it settles
everything it still owes and answers the outstanding requests — including the shutdown itself —
with SessionShutdownTimeoutError. close() still resolves, with result { outcome:
"interrupted" } after session_shutdown { reason: "forced" }, because the session is ending
cleanly rather than breaking.
The order matters if you read frames yourself: the active turn's own completion comes first,
carrying interrupted and no usage, then the other outstanding requests, then the shutdown reply,
then the terminal pair. A program that stops reading at the shutdown reply misses the turn report
it is entitled to.
SessionShutdownTimeoutError says the request did not complete. It does not say the request
failed on its own terms, and it says nothing either way about whether its effect happened — a
peer_send answered this way may or may not have published.
await using performs that clean close at scope exit; using kills the host outright. Without
either, call close() or kill() yourself — a session left open is a child process left running.
Turns you did not ask for
An admitted peer message can open a turn. The host brackets that turn with peer_turn_started and
peer_turn_complete, which is how a program learns it happened at all — nothing requested it, so
there is no promise resolving to report it.
import { createAgentSession } from "@gomomento-poc/mo-sdk";
const session = await createAgentSession({
model: "momento/zai-org/GLM-5.2",
intercom: { name: "planner", onMessage: async () => "admit" },
});
session.on("peer_turn_started", ({ turn_id }) => console.log(`${turn_id} began`));
session.on("peer_turn_complete", ({ turn_id, outcome, usage }) => {
console.log(turn_id, outcome, usage?.prompt_tokens ?? "usage not reported");
});Two things here are easy to get wrong, and both fail quietly.
The pair brackets only turns nobody requested. A prompt turn is bracketed by neither, because
the promise prompt() returns is that turn's report. Counting both would count one turn twice.
Absent means unmetered; zeros are a measurement. undefined is "nobody measured this turn",
never "the turn was free", so do not default it to zero. A turn genuinely metered at zero reports
zeros, so those you may read as measured. A failed turn reports absent rather than zero, because
the model may well have run.
The outcome vocabulary is the same one prompt() resolves with, so a program that classifies
outcomes does it once. The host owns that vocabulary and may grow it, so a word this version does
not know reaches you as itself — switch with a default arm.
Correlation is yours. The SDK hands over the frames and the turn_id that pairs them, and
keeps no ledger: a completion with no start, a repeated start, or a second completion all reach
you unchallenged. That is deliberate, because these are observations and an orphan can legitimately
follow a frame that could not be sent or a subscription added late. Deduplicate on turn_id if it
matters to you.
Two diagnostics arrive on mo/error and mean different things. peer_turn_failed says a turn an
admitted message opened could not run. peer_turn_unreported says one of these lifecycle frames
could not be sent — they ride the same budget as model output, so an embedder that stops draining
can fill it — and the turn itself ran or is running. Treating the second as the first has a program
conclude a turn that happened never did.
Groups
A session hears a group only if it declared one at open. The model has no action that joins one, so what a session hears is your decision, and changing it means opening a new session.
import { createAgentSession } from "@gomomento-poc/mo-sdk";
const session = await createAgentSession({
model: "momento/zai-org/GLM-5.2",
intercom: { name: "planner", groups: ["round-1", "scores"] },
});
// What the host made ready, which may be less than was asked for.
console.log(session.intercom?.groups);
await session.intercom?.send("#round-1", "the board is up");Names are lowercase ASCII letters, digits and ._-, one to thirty-two characters. At most eight
groups, counted after duplicates collapse: naming one twice declares one, so lists that overlap are
not over the bound just because they overlap. There is no normalization, so two different spellings
are two different groups. The character set excludes every separator the wire uses, so a name
cannot splice into a neighbouring field.
The SDK checks all of that before sending configure, because the host's refusal is fatal: it ends
the session rather than the call.
Compare intercom.groups against what you declared. It lists what came up ready, not what was
asked for, and a group missing from it is one you cannot address.
A # target is only ever a group. An undeclared one is refused by name rather than searched for
among the peers, so the PeerUnresolvedError carries no candidates. A name outside the grammar is
an ordinary SessionRequestError with the host's own code, not an unresolved target: the name was
never addressable in the first place.
transport picks which topics group traffic rides, and defaults to peer-topic, which needs no
grant beyond the peer one. group-topics is refused by the current host build — at configure
and again before the vend — so send peer-topic or omit the field. That is deliberate rather than
an oversight: a session that believed it had per-group topics while riding the peer topic is a
rollout reporting itself complete when it is not, so the stage is refused rather than quietly
served by the one below it.
replyTo and a #group target do not go together, and the host refuses the pair: a reply is
addressed to whoever sent the message you are answering, which is a peer and not a group. A group
send is also unattested and carries no delivery receipt.
Opening many at once
Each session is a child process, and its startup — loading the binary, the handshake, reading a workspace — is the expensive part. What the bound covers is concurrent handshakes: a slot frees when the handshake settles, so a host being torn down after a failed one does not hold the fleet behind it.
A Promise.all over a fleet would otherwise land every one of those in the same instant, so the
SDK bounds how many may be starting at once. Nothing is dropped, only queued: twelve sessions
opened together all open.
The bound defaults to the parallelism available to the process, capped at 4. It is process-wide rather than per call, because a per-call option would let two parts of one program each assume they were the only ones spawning.
import { configureSdk } from "@gomomento-poc/mo-sdk";
configureSdk({ spawnConcurrency: 8 }); // once, at startupIt bounds startup, not how many sessions may be open at a time.
A presence on the plane
A program that wants to be addressable — a scoreboard, a referee, the thing that collects what the
agents say — does not want an agent. openIntercom gives it the plane and nothing else: a
receive-only session, which is the one kind the host runs without a model and never starts a turn
for.
import { openIntercom } from "@gomomento-poc/mo-sdk";
await using stage = await openIntercom({ name: "scoreboard" });
stage.onMessage(({ sender_name, text }) => console.log(`${sender_name ?? "?"}: ${text}`));
await stage.send("planner", "the board is up");
const peers = await stage.peers();
await stage.state.set("board:round", { round: 1 });It resolves once the plane is live, so a handle you hold is one peers can reach. peerId is the id
they address. There is no prompt: a presence has no model, and a method whose only outcome is the
host refusing it is not worth having.
onMessage is observational and that is all it needs to be. Admission decides whether a message
reaches a model, and a presence has none — so every message the plane delivers reaches your
handler. Pass it as an option to have it registered as early as the SDK can; call it on the handle to
register later. The first handler also receives what arrived while the session was still opening,
which the session holds for it — up to 64 messages, after which the rest are dropped and
onError says so. A presence never hears its own sends: the plane's router drops a sender's
own frames.
onError is worth subscribing to. It is the only channel some things have: the held groups
above, a handler of your own that threw, a plane that stopped being reachable after opening. A
listener that never subscribes can go quiet without being told why.
state is a StateHandle — a fresh in-process one by default, or pass your own to share a record
with the rest of the program. See State.
groups declares what this presence hears, and stage.groups is what the host actually made
ready. See Groups.
Opening several sessions
There is no openSessions helper, deliberately. A loop is shorter than the options bag it would
take, and it leaves the failure policy where it belongs — with the program, which is the only thing
that knows whether a missing member means carry on or give up.
import { createAgentSession, type AgentSession } from "@gomomento-poc/mo-sdk";
declare const roles: Array<{ name: string; preamble: string }>;
// All or nothing: one failure rejects, and the rest are closed rather than left running.
const opening = roles.map((role) =>
createAgentSession({
model: "momento/zai-org/GLM-5.2",
context: { preamble: role.preamble },
intercom: { name: role.name },
}),
);
const settled = await Promise.allSettled(opening);
const open: AgentSession[] = settled.flatMap((one) => (one.status === "fulfilled" ? [one.value] : []));
if (open.length !== roles.length) {
await Promise.all(open.map((session) => session.close().catch(() => undefined)));
throw new Error(`only ${open.length} of ${roles.length} roles opened`);
}Opening them together is safe: the SDK bounds how many hosts may be starting at once, so the fleet queues rather than storming. See Opening many at once.
Context budget
compactionThresholdPercent is a percentage, not a token count, because both inputs are the host's:
the route's served context window, and the correction for the token estimator's measured under-count
on anthropic/* routes — which a caller cannot compute at all. Omit it and the session runs
uncompacted, which is right for a short scripted leg and wrong for a long role, since nothing
else bounds the message list.
State
StateHandle is the shared-state revision contract as an interface: a
key-value record several sessions on one login read and write, with compare-and-set so two
cooperative writers cannot both succeed against the same revision.
Two implementations behind the one interface. memoryState() is in process and private to it.
state: true on a session asks the host for the record other sessions on this login can see, and
puts the same interface on session.state. Moving between them is a change of handle, not of code.
import { createAgentSession, memoryState, type StateHandle } from "@gomomento-poc/mo-sdk";
// In process, private to this program:
let state: StateHandle = memoryState();
// Or the record the rest of the login shares. `session.state` is optional because most sessions
// never ask for one, so this narrows the type — it is not a guard: an open that asked for state and
// returned has it.
const session = await createAgentSession({ model: "momento/…", state: true });
state = session.state ?? state;
// Everything below is the same code either way, which is the point of the interface.
const created = await state.set("league:table", { round: 1 }, { ifAbsent: true });
if (!created.ok) console.log(created.reason); // "exists"
const current = await state.get("league:table");
if (current.ok) {
const won = await state.cas("league:table", current.rev, { round: 2 });
if (!won.ok) console.log(won.reason); // "stale" | "missing" | "unknown"
}
const bumped = await state.update("league:table", (value) => ({
round: ((value as { round: number } | undefined)?.round ?? 0) + 1,
}));
if (!bumped.ok) console.log(bumped.reason); // "exhausted" | "missing" | "unknown"Every method resolves to { ok: true, … } or { ok: false, reason }, with the contract's own name
for each reason. unknown is a write the host sent and got no answer to: it may or may not have
landed, and nothing retries it. A refusal throws a StateError whose code is also the contract's
name — bad_key, bad_ttl, bad_rev, too_large, too_deep, bad_schema (with a reason) for
a stored record the reader cannot present as data, and the host-only rate_limited, denied,
unavailable and error.
Keys are [a-z0-9._:-]{1,128}. Data is any JSON value, at most 64 KiB serialized and 32 levels
deep. Every write carries a TTL in seconds (1 to 86400, default 3600); a record can expire between
a read and its cas, and the answer is stale. update reads, applies a pure function (which
receives undefined for an absent key), writes, and retries a lost race up to maxAttempts
(default 8, at most 32), so the function may run more than once. It does not retry a deletion: that
answers missing.
memoryState(store) shares one map between handles, which is how a test models several sessions on
one login, or the contract's direct writer: a record placed in the store by hand is read with the
same checks a host applies.
session.state is undefined unless the session asked for state, so which sessions hold a record
is a question the type answers rather than one every call discovers. Asking for it and being
refused fails the open instead, because a session that cannot hold the record it asked for should
say so before a program has decided it has one. A session holding no grant answers every call
unavailable. openIntercom({ state: true }) gives a presence the same handle, which is what a
scoreboard is.
Entry points
| Import | Contents |
|---|---|
| @gomomento-poc/mo-sdk | createAgentSession, openIntercom, configureSdk, memoryState and StateHandle, the protocol types, the codec, the transport |
| @gomomento-poc/mo-sdk/protocol | Wire types alone |
| @gomomento-poc/mo-sdk/codec | decodeLine / encodeLine / LineDecoder, for a host you transport yourself |
| @gomomento-poc/mo-sdk/transport | spawnTransport, for a process you supervise yourself |
What the handshake settles
session.hostVersion is the host binary's version as it announced itself — informational, never
something to branch on, but the first thing a version-skew report needs. session.maxFrameBytes is
the per-frame byte budget in force: what the host advertised, capped at 16 MiB. Sending a frame over
it throws rather than writing a line the host would reject, which is worth knowing before you put a
large result in a custom tool.
A resolver that takes longer than the host's window loses the decision: the host has already failed
that call closed, so the SDK stops holding the slot and does not send the late answer. It reports an
mo/error with code callback_expired when that happens.
Errors
Three, and which one you caught tells you what to do next. SessionRequestError is the host
declining something on purpose — it carries the JSON-RPC code, and the session stays usable.
SessionProtocolError is a host reply this SDK could not read — the host is not speaking the
protocol you compiled against, so check its version. From prompt the session survives it; from
createAgentSession the handshake failed and the child is already stopped.
SessionClosedError is the session being over, and every promise still pending rejects with it.
Building a worker
Everything a containerised multi-role worker needs is above: a role is a set of session options,
a structured decision is a terminal custom tool, pausing is a tool result with pause: true plus
session.resume in the next process, and policy the model must not negotiate goes in permissions
and the two hooks. Ask your Momento contact for the integration guide if you want those assembled
into a worked example.
License
Apache-2.0
