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

EntryImports
@nyte-ai/corecreateNyte, 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/storeSqliteStore, 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.

DeliveryLands
steerAt the next response boundary, or when the head is idle
nextOnly 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

OperationOutcomeUse
runs.waitidle | waiting { runId } | cancelledBlock until the head is free, a tool parks, or the caller cancels.
runs.replysignalled | not_waiting | not_foundAnswer the exact parked generation by callId and waitId.
runs.abortrequested { runId } | not_runningStop the live run. What was already said is kept.
runs.compactcompacted { 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.

KindFold
commitappendTranscriptCommit; undefined means re-snapshot.
head_movedThe tip the next commits will follow. Clear any provisional text.
runThe head's RunInfo, including origin, root, and phase. A terminal phase drops the run's overlay.
jobUpsert event.job by job ID, or refresh jobs.list.
queued / landed / queue_cancelledThe pending gutter. Pending is also a store read, never client memory.
text_delta / reasoning_deltaProvisional text keyed by (runId, attempt, index); the commit settles it.
tool_progress / effectA call's live output and durable state: intent, waiting, expired, signal, result.
stack / fact / deletedHead metadata, a small session value, the session going away. A deleted fact carries no value.
compactionCheckpoint work in flight on that head; compaction: null means it ended.
config_queuedA configuration choice joined the queue before anything landed.
activation_changedHost-local activation state, replayed once per watch and not ordered by seq.
syncedReplay is over; what follows is live.
diagnostic / plugins_changedA notice to show, and the plugin list to re-read.
notification / status_changedA 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.

  1. Build Models, register the providers this product supports, resolve a credential.
  2. Gate project plugins on workspace trust (WorkspaceStore from @nyte-ai/host). The grant lives in ~/.nyte/workspaces.json and is inherited from a trusted ancestor. Print mode will not grant trust.
  3. Resolve the plugin list with resolvePlugins, which reads built-ins, then ~/.nyte/plugins, then <cwd>/.nyte/plugins, filtered by the merged nyte.json manifests (~/.nyte and <cwd>/.nyte), which also name MCP servers. Watch those sources with watchPluginDirectories and pass nyte.holdPlugins as its hold, so a runner's next step waits for setPlugins.
  4. Pass that list, streamFn, store, and env.cwd into createNyte, or pass resolveActivation to load plugins the first time a session activates.
  5. Call attach() in the process that should run tools.
  6. Supply workspace.vcs (createGitVcs from @nyte-ai/host) so nyte.workspace.vcs answers snapshot, diff, contents, log, refs, stage, discard, commit, createBranch, and push for the session's directory. Without it, reads answer empty and writes answer failed. Mutation expect protects 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.