SDK
createNyte is how a host talks to Nyte. A store, a stream function, a catalog, and plugins go in; operations and one event stream come out
Compose one Nyte over a store, a stream function, a model catalog, and a plugin list. Clients call
operations on that object and fold its events. The design record holds the
full contract.
messages.send admits a message into the inbox. It does not start a run. The process that called
attach() runs it, and without an attached runner runs.wait sits forever on a message the store
already holds.
Two entries
| Entry | Imports |
|---|---|
@nyte-ai/core | createNyte, the wire data types, and MAIN. The operation table, envelopes, SSE codec, and CursorExpired stay in @nyte-ai/protocol. The projections live in @nyte-ai/client. |
@nyte-ai/core/store | SqliteStore, WorkerStore (the same backend in a worker thread, for hosts that also render), and the Store contract a backend implements. Hosts name their storage here. Clients never import it. |
Plugin authoring is @nyte-ai/core/plugins. @nyte-ai/plugin re-exports it and adds define for
local TUI plugins. SqliteStore takes one file path. Nyte's clients share
~/.nyte/workspaces/<path-hash>/sessions.db per workspace, resolved by workspaceStorePath from
@nyte-ai/host.
import process from "node:process";
import { createModels, FileCredentialStore, openaiCodexProvider } from "@nyte-ai/ai";
import { createNyte, MAIN } from "@nyte-ai/core";
import { inlinePlugin, systemPromptPlugin, toolsFsPlugin } from "@nyte-ai/core/plugins";
import { SqliteStore } from "@nyte-ai/core/store";
import { workspaceStorePath } from "@nyte-ai/host";
const cwd = process.cwd();
const store = new SqliteStore(await workspaceStorePath(cwd));
const models = createModels({ credentials: new FileCredentialStore() });
models.setProvider(openaiCodexProvider());
const model = models.getModel("openai-codex", "gpt-5.6-luna");
if (model === undefined) throw new Error("model unavailable");
const nyte = await createNyte({
store,
streamFn: (requested, context, options) => models.streamSimple(requested, context, options),
models,
model,
drain: "one",
plugins: [inlinePlugin(systemPromptPlugin()), inlinePlugin(toolsFsPlugin())],
env: { cwd },
});
const detach = nyte.attach();
const session = await nyte.sessions.create();
const printer = (async () => {
for await (const event of nyte.watch({ sessionId: session.sessionId, live: true })) {
if (event.kind === "text_delta") process.stdout.write(event.delta);
}
})();
await nyte.messages.send({
sessionId: session.sessionId,
head: MAIN,
content: "list the TypeScript files here",
});
await nyte.runs.wait({ sessionId: session.sessionId });
detach();
await nyte.close();
await store.close();drain is "one" by default; "all" lands the whole selected delivery. The send above could
omit head to use MAIN. jobs.list without a head filter lists jobs across the session's heads.
The client loop
A client opens with sessions.snapshot, draws it, then watches from the snapshot's seq. Each
commit event folds into the transcript with appendTranscriptCommit. That function returns
undefined when the commit's parent is not the tip it holds, which means the head moved. Take a new
snapshot. A cursor older than the event floor throws CursorExpired from watch, with the same
answer.
import { appendTranscriptCommit } from "@nyte-ai/client";
import type { TranscriptState } from "@nyte-ai/client";
import type { Nyte } from "@nyte-ai/core";
import { CursorExpired } from "@nyte-ai/protocol";
import type { SessionId } from "@nyte-ai/protocol";
async function follow(
sdk: Nyte,
sessionId: SessionId,
draw: (transcript: TranscriptState) => void,
): Promise<void> {
for (;;) {
const snapshot = await sdk.sessions.snapshot({ sessionId });
if (snapshot === undefined) return;
let transcript: TranscriptState = { items: snapshot.transcript, tip: snapshot.tip };
draw(transcript);
let refold = false;
try {
for await (const event of sdk.watch({ sessionId, afterSeq: snapshot.seq })) {
if (event.kind !== "commit" || event.head !== snapshot.head) continue;
const next = appendTranscriptCommit(transcript, event.item);
if (next === undefined) {
refold = true;
break;
}
transcript = next;
draw(transcript);
}
} catch (cause) {
if (!(cause instanceof CursorExpired)) throw cause;
refold = true;
}
if (!refold) return;
}
}watch({ afterSeq }) replays retained events, emits synced, then stays live. watch({ live: true })
skips the replay. The snapshot already carries pending, run, and context.
Sends go through an outbox
Mint a key on Enter, keep the message locally as sending, draw it in the pending gutter at once,
and submit with that key until the store answers. queued and duplicate both mean the message is
durable, so a retry after a lost response lands nothing twice.
async function submit(
sdk: Nyte,
sessionId: SessionId,
content: string,
sleep: (ms: number) => Promise<void>,
): Promise<string> {
const key = crypto.randomUUID();
// Keep { key, content } locally as "sending" and draw it now.
for (let attempt = 1; ; attempt += 1) {
try {
const receipt = await sdk.messages.send({ sessionId, content, key });
// queued and duplicate both mean the store holds it: drop the local row.
return receipt.change;
} catch {
await sleep(Math.min(10_000, 250 * 2 ** (attempt - 1)));
}
}
}The inbox
Each head has two deliveries with fixed timing.
| Delivery | Lands |
|---|---|
steer | At the next response boundary, or when the head is idle |
next | Only when the head is idle |
messages.send defaults to delivery: "next"; use "steer" for input that
should join a live run at its next boundary. The submitter also stamps the
change as user input. Configuration is passive, delegated answers are answers,
and command completions are reports. Admission uses those stamps rather than
inspecting the body later.
messages.redeliver can change a still-pending item's delivery. It also accepts content to edit
the message and before to reorder it within the target delivery. Omit before to preserve its
position, or pass null to move it to the end. The changed suffix receives new change IDs in one
atomic update, and an untouched prefix keeps its IDs. messages.cancel withdraws one. A cancel
racing landing settles as cancelled or landed, never both.
Runs
| Operation | Outcome | Use |
|---|---|---|
runs.wait | idle | waiting { runId } | cancelled | Block until the head is free, a tool parks, or the caller cancels. |
runs.reply | signalled | not_waiting | not_found | Answer the exact parked generation by callId and waitId. |
runs.abort | requested { runId } | not_running | Stop the live run. What was already said is kept. |
runs.compact | compacted { commit } | aborted | nothing_to_compact | busy { run } | failed { code } | Write one checkpoint now, with the branch's own model and thinking level. |
runs.compact's failed carries a code of internal, inactive, busy, conflict, or
fenced. Switch on the code, not the message.
const outcome = await sdk.runs.wait({ sessionId });
if (outcome.kind === "waiting") {
const snapshot = await sdk.sessions.snapshot({ sessionId });
const parked = snapshot?.parked?.find((call) => call.selection !== undefined);
if (parked !== undefined) {
await sdk.runs.reply({
sessionId,
runId: parked.runId,
callId: parked.callId,
waitId: parked.waitId,
reply: { choices: ["blue"] },
});
}
}
const compacted = await sdk.runs.compact({ sessionId, customInstructions: "keep file paths" });A parked participant selection needs no live process. Its refs hold the choices, an optional
own-answer placeholder, an optional deadline, and a waitId for that exact wait generation.
Re-parking the same call changes waitId, which tells clients to clear local picks and open the
replacement. Running jobs still need their owning host.
runs.context is the token gauge. runs.diff is the per-run file diff, exact from the trees
recorded on the run's commits when the host has a VCS backend and from file_patch facts otherwise.
runs.revert restores the run's files from its first tree.
Jobs
Core tracks bash commands as jobs. The tool accepts background: true. Children are sessions,
not jobs: the parent addresses one by SessionId through create, send, await, read, and
stop, and sessions.list({ parent }) finds them. Aborting a run cancels that run's own command
jobs and its parked waits; the children keep working.
To move already-running work to background, use its job ID, not its run ID.
import type { Nyte, SessionId } from "@nyte-ai/core";
async function backgroundWork(sdk: Nyte, sessionId: SessionId) {
const jobs = await sdk.jobs.list({ sessionId, head: "main" });
const job = jobs.find((job) => job.phase.kind === "running" && job.phase.mode === "foreground");
if (job === undefined) return;
return sdk.jobs.background({ sessionId, jobId: job.id });
}
async function cancelJob(sdk: Nyte, sessionId: SessionId, jobId: string) {
return sdk.jobs.cancel({ sessionId, jobId });
}jobs.background and jobs.cancel return applied, finished, or not_found. Backgrounding keeps
the same work running and releases the parked parent call with a receipt. Cancelling targets only
that job. runs.abort cancels foreground and background work owned by that run, and leaves
independent user jobs running. These controls work while the parent run is waiting, so do not gate
them on an idle composer.
JobInfo includes id, head, origin, command, phase, timestamps, and output. origin
is { kind: "run", runId, callId } for a job a tool call started and { kind: "user" } for one
jobs.start began. phase is running with a foreground or background mode, or
completed, failed, cancelled, or interrupted. Output retains the last 50,000
characters. Read jobs with jobs.list and refresh on job events. Job data is separate from the
session snapshot.
Job records and output are durable, but work does not survive the owning host closing. Host close or
recovery marks abandoned work interrupted without rerunning it. A terminal background job changes
its completion from owed to claimed, submits its end and output under an idempotency key on the
originating head, then records delivered. Recovery retries claimed delivery with the same key.
runs.abort suppresses only a completion still owed; one already claimed finishes exactly once.
Completions are submitted with kind: "report" and delivery: "steer"; a child's answered request
uses kind: "answer" and the same delivery. User input starts model work. A delegate's answer can also start one continuation when
an un-stopped run authorized that request. The send writes its authorization while asserting the
exact parent run ref in the same CAS, and landing consumes the authorization once. Stop either wins
that assertion before the child submission or revokes the stored authorization. Command completions,
participant child requests, and unauthorized delegate answers wait for user input when no run is
active. Clients do not submit completion messages themselves.
Children inherit workspace trust and are not offered tools marked
availability: "foreground".
Heads
heads.move keeps one main pointer and keeps abandoned branches. Selecting a user message parks
the head on its parent and hands the original content back in restored. With summary, the commits
the move abandons are summarized into one summary commit whose parent is the target, in the same
compare-and-swap a plain move uses. Nothing abandoned means a plain move. A model failure returns
failed and leaves the head where it was.
const moved = await sdk.heads.move({
sessionId,
to: user.commit,
expect: snapshot.tip,
summary: { customInstructions: "what the abandoned attempt learned" },
});
if (moved.kind === "moved") {
void moved.restored?.content; // hand the selected message back to the composer
void moved.summary; // the summary commit, when anything was abandoned
}The outcomes are moved, busy { run }, moved_since { tip }, not_found, and failed. expect
rejects a view that was already stale. heads.create cuts a stacked head from another head or a
commit. heads.merge fast-forwards a current stacked child into its parent.
Events a client folds
Every event carries seq. A client keeps the last one it applied as its cursor.
| Kind | Fold |
|---|---|
commit | appendTranscriptCommit; undefined means re-snapshot. |
head_moved | The tip the next commits will follow. Clear any provisional text. |
run | The head's RunInfo, including origin, root, and phase. A terminal phase drops the run's overlay. |
job | Upsert event.job by job ID, or refresh jobs.list. |
queued / landed / queue_cancelled | The pending gutter. Pending is also a store read, never client memory. |
text_delta / reasoning_delta | Provisional text keyed by (runId, attempt, index); the commit settles it. |
tool_progress / effect | A call's live output and durable state: intent, waiting, expired, signal, result. |
stack / fact / deleted | Head metadata, a small session value, the session going away. A deleted fact carries no value. |
compaction | Checkpoint work in flight on that head; compaction: null means it ended. |
config_queued | A configuration choice joined the queue before anything landed. |
activation_changed | Host-local activation state, replayed once per watch and not ordered by seq. |
synced | Replay is over; what follows is live. |
diagnostic / plugins_changed | A notice to show, and the plugin list to re-read. |
notification / status_changed | A plugin asking for attention, and the status items to show beside the model. |
A user run has origin: { kind: "user" } and uses its own runId as root. A continuation
origin names the child session and its request as { kind: "commit", oid } after landing or
{ kind: "change", oid } when stopped first. It keeps the requesting run's root. The root
identifies the aggregate response-attempt budget across the chain. Core reserves each attempt in
that root's counter before the provider call, so concurrent continuations cannot both spend the
last slot. Retry phases carry retry { at, retries, failure }, where retries is the consecutive
retry count.
The checkpoint a compaction writes arrives as an ordinary commit. The compaction event is
progress, not a second history.
What the host still owns
createNyte does not pick a provider, load plugins, or decide trust. You do that first.
- Build
Models, register the providers this product supports, resolve a credential. - Gate project plugins on workspace trust (
WorkspaceStorefrom@nyte-ai/host). The grant lives in~/.nyte/workspaces.jsonand is inherited from a trusted ancestor. Print mode will not grant trust. - Resolve the plugin list with
resolvePlugins, which reads built-ins, then~/.nyte/plugins, then<cwd>/.nyte/plugins, filtered by the mergednyte.jsonmanifests (~/.nyteand<cwd>/.nyte), which also name MCP servers. Watch those sources withwatchPluginDirectoriesand passnyte.holdPluginsas itshold, so a runner's next step waits forsetPlugins. - Pass that list,
streamFn,store, andenv.cwdintocreateNyte, or passresolveActivationto load plugins the first time a session activates. - Call
attach()in the process that should run tools. - Supply
workspace.vcs(createGitVcsfrom@nyte-ai/host) sonyte.workspace.vcsanswers snapshot, diff, contents, log, refs, stage, discard, commit, createBranch, and push for the session's directory. Without it, reads answer empty and writes answerfailed. Mutationexpectprotects against acting on a moved tree; the revision is a public digest, not proof that the client read that tree.
Trust is not a sandbox and not a per-call approval. After trust, project plugins and unrestricted filesystem tools load.
An agent is a plugin contribution, and a child is a child session with durable parent coordinates. See design, Agents.