Cortex · README.md

This is README.md as @niscorp/cortex 0.1.4 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/cortex/README.md

@niscorp/cortex

Agent runtime. One tool loop, typed envelopes, code-hook gates, streamed events. Two peer dependencies — the app owns the one copy of each, because both cross the API: @niscorp/signal (you hand cortex the client) and zod (you hand it the schemas). @niscorp/solid is an ordinary dependency.

pnpm add @niscorp/cortex @niscorp/signal zod
# Signal loads the provider's SDK when a model is called. For Groq, OpenAI,
# OpenRouter or any OpenAI-compatible API (the quick example runs on Groq):
pnpm add openai

For the why behind the architecture — the envelope, output strategies, the append-only loop, gates over rules — see DESIGN.md.


Quick example

import { z } from 'zod';
import { createSignal } from '@niscorp/signal';
import { defineAgent, defineTool } from '@niscorp/cortex';

const calculator = defineTool({
  id: 'calculator',
  name: 'calculator',
  description: 'Evaluates a simple arithmetic expression.',
  input: z.object({ expression: z.string() }),
  execute: ({ expression }) => new Function(`return (${expression})`)(),
});

const calcAgent = defineAgent({
  id: 'calc',
  description: 'Arithmetic with a tool.',
  instructions: 'Use the calculator tool, then give the answer.',
  tools: [calculator],
  output: { schema: z.object({ answer: z.number() }) },
});

const llm = createSignal('groq'); // qwen/qwen3.8-27b — the registry default

const result = await calcAgent.run('What is (2 + 3) * 7 - 6?', { llm }).result;
if (result.ok) console.log(result.output.data.answer); // 29
else console.error(result.error.message); // a run that fails returns its reason; it does not throw

The envelope

Every agent returns the same shape — there is no separate text mode:

type Envelope<TData> = {
  response?: string;   // human-facing text
  data: TData;         // schema-typed payload (undefined for pure chat agents)
  reasoning?: string;  // the model's short WHY
};

A chat agent omits output.schema (its response becomes required); a data agent sets it; a rich assistant returns both — prose and a payload in one turn. Run-level metadata (usage, resolved strategy, attempts, elapsed) is authored by cortex on result.meta, never by the model. meta.usage.reported is false when any step's tokens were counted by Signal instead of reported by the provider; the totals are then an estimate.

const result = await agent.run(input, { llm, deps }).result;
// { ok: true,  output: Envelope<T>, meta: RunMeta }
// { ok: false, error: CortexError,  meta: RunMeta }

Output strategies

How the envelope travels from model to runtime. One field, output.strategy, default 'auto':

Strategy Mechanism Fits
native provider response_format: json_schema, in-loop small strict-compatible schemas on capable providers (auto-picked)
respond a synthesized respond tool; the envelope is its arguments where the tool params can ENFORCE the contract — the endpoint does not validate tool args itself, the schema is small, the model carries nested args intact (auto-picked there)
emit the model's completion IS the envelope (content channel) everything else — auto-picked wherever neither of the above enforces anything: all of Groq, big schemas, models that mangle nested tool args

Zod validates in every strategy. Invalid output feeds back as a correction inside the same run (the attempt and a system message naming the issues are appended) — never a full re-run. Big recursive schemas (Prism nodes, Nova actions) ride emit; cortex injects the JSON Schema into the prompt automatically (output.doc: 'auto', via schemaDoc() — single-sourced from the Zod schema).

Async output validation closes generate→verify loops in-run:

output: {
  schema: ActionDefinitionSchema,
  validate: async (out) => (await mounts(out.data)) ? { ok: true } : { retry: 'why' },
}

defineAgent

const agent = defineAgent<Data, Deps>({
  id: 'action.builder',
  description: 'One sentence.',
  llm,                                   // per-agent model binding (optional)
  instructions: ({ deps }) => `…`,       // string, or function of typed deps
  context: [                             // functions that build the prefix, ONCE per run
    'A static system chunk.',
    ({ deps }) => renderCatalog(deps.palette),
  ],
  tools: [queryTool],                    // definitions, not id strings
  output: { schema: DataSchema, validate, strategy: 'auto', forceTool: false, doc: 'auto' },
  stopWhen: [stepCount(16), tokens(100_000), duration('6m'), outputRetries(3)],
  policy: { tools: { requireApproval: ['transfer'], maxRiskLevel: 'medium' } },
  toolGates: [myGate],                   // code hooks, run BEFORE each call
  onToolResult: [redactor],              // replace/redact results
  prepareStep: ({ step, usage }) => ({ activeTools, toolChoice, inject, llm }),
});

Deps are per-invocation, typed, and consumed by context entries, gates and hooks — agents are defined once, never rebuilt per call. Defaults when stopWhen is omitted: stepCount(20), outputRetries(3). A stopWhen you pass replaces both, so keep a stepCount in it: outputRetries counts refused answers only, and a turn of prose with no envelope in it is corrected without being counted. outputRetries(n) ends the run at the n-th refused answer: n attempts, n − 1 corrections, so outputRetries(1) allows no second attempt. repeatedCalls(n) ends the run when a tool call has got the same answer as the call before it n times in a row — the same tool, the same arguments, the same result or error — which is n + 1 identical calls.

policy.tools lists take tool ids (tool.id), not the names the model calls: a name in deny or requireApproval matches nothing and says nothing, and a name in allow leaves the tool off the list.

prepareStep's activeTools is the other way round: it takes the names the model calls, the strings its tools argument lists, and a tool whose name is not in it is not offered on that step. The respond tool cannot be masked.

transport: 'step' calls the model without streaming: the run emits no model-delta, output-delta or output-partial. It is for a provider and model pair that assembles tool-call arguments more reliably outside streaming. The default is 'stream'.

Multi-turn history is caller-owned: input accepts a string, a Message[] transcript, or any JSON value.

Per-run additions ride the options:

agent.run(input, {
  llm, deps,
  tools: [perRequestTool],   // appended to the agent's static tools
  gates: [uiApprovalGate],
  onToolResult: [traceHook], // run after the agent's own hooks
  onEvent: (e) => …,
  signal: abortController.signal,
});

Events — the run is the stream

const run = agent.run(input, { llm, deps });
for await (const e of run.events) {
  // run-start · step-start · model-delta · tool-start · tool-end
  // approval-required · output-delta · output-partial · retry · run-end
}
const result = await run.result;   // the no-streaming opt-out
  • run-start is emitted before agent.run() returns: the onEvent option and run.events deliver it, a listener added with run.onEvent does not.
  • tool-start fires before execution — drives live "running…" UIs.
  • tool-end carries a typed ToolObservation union (result / error / denied / unknown-tool). No casting. Through Signal, unknown-tool is rare: Signal routes only calls to tools the step declared, and reads a call to any other name as an attempted answer. It is validated as the envelope (a retry, or the run's output if it passes), or dropped when it comes beside a call to a declared tool, and never reaches tool-start. The kind arises when resumeRun is not handed the per-run tool the pending call names, or with a client that does its own routing.
  • output-delta streams the raw envelope JSON as it generates; output-partial streams the progressively parsed envelope via @niscorp/solid — a chat response streams token-ish, a Nova screen in data becomes renderable before the run ends. Both stream before cortex knows how the step ends: words written beside a tool call (under emit and native) and a respond sent beside other calls arrive as output-delta too and are not the answer. The answer is what the last step streamed. output-partial is best-effort: a run emits none when Solid cannot follow the schema or the text, and the run's result is validated on its own either way.
  • retry resets partial-output state (same contract consumers had in v1). Its kind says what is retried: output — an envelope arrived and was refused; termination — the step ended with no envelope in it; provider — the provider rejected the model's attempt on its side. Only output counts against outputRetries, and attempt numbers each kind on its own.
  • Nested runs (asTool) forward their events into the parent stream with an extended agentPath — one subscription sees the tree.

Gates and approvals

All steering is plain typed functions; policy is sugar that compiles into one gate. Gates run before the call; a deny becomes a tool error the model reacts to; the run continues. They run in order — the policy gate, the agent's toolGates, the manifold's gates, the run's gates — and the first deny ends the chain.

type GateDecision =
  | { allow: true; args?: unknown }   // optionally rewrite args
  | { deny: string }
  | { ask: { reason: string } };      // suspend for a human

ask emits approval-required and suspends the run:

run.onEvent((e) => {
  if (e.type === 'approval-required') {
    void showDialog(e.approval).then((ok) =>
      ok ? run.approve(e.approval.id, { args: maybeEdited }) : run.deny(e.approval.id, 'user said no'),
    );
  }
});

Suspended runs serialize: run.snapshot() → JSON → resumeRun(agent, snapshot, { llm, deps }) re-asks the pending approval and continues — approvals survive reloads and restarts. Re-asks means just that: the resumed run's gates run again for the pending call and approval-required fires again, with a new id. Answer with that event's id, as above. snapshot.pending.approvalId is the ask of the run the snapshot was taken from; the resumed run did not ask under it, and approve or deny with it does nothing. The resumed run keeps the snapshot's run id, transcript, step and output-retry counts, usage and elapsed time, so stepCount, tokens, duration and outputRetries count on from there. policy.approvalTimeoutMs turns an unanswered ask into a denial (an observation, not a run failure). The denial's reason is approval timeout.


The manifold

A catalog, defaults, and a tap — agent.run() and manifold.run() are the same execution path.

const manifold = createManifold({
  llm: groqQwen,                  // fallback model
  gates: [uiApprovalGate],        // appended to every run
  onRun: (run) => trace.attach(run),
});

manifold.register(vexQueryAgent, novaLayoutAgent, queryTool);
const run = manifold.run<Query>('vex.query', input, { deps });

Delegation is a tool call:

import { asTool } from '@niscorp/cortex';

const orchestrator = defineAgent({
  id: 'orchestrator',
  instructions: 'Delegate, then respond.',
  tools: [asTool(vexQueryAgent, { deps, llm: groqQwen })],
});
// or from the catalog: manifold.asTool('vex.query')

The child's envelope maps to the tool result (data if present, else response; override with select), and its events nest in the parent stream.

A tool that runs an agent itself gets the same nesting from the context execute is handed: agent.run(input, { onEvent: ctx.forward, agentPath: ctx.agentPath }). forward puts the child's events on the parent's stream, and the child appends its own id to the path it is given.


preview

The exact messages and tools a run would send — no model call:

const p = await agent.preview(input, { deps, llm });
p.strategy;          // 'respond' | 'native' | 'emit' — what would actually run
p.messages;          // the assembled prefix, including injected schema docs
p.tools;             // real descriptors, including the respond tool's params
p.estimatedTokens;   // rough: characters / 4, plus 4 per message and 256 per non-text part

Anything you can't explain in the preview, the model can't either.


Errors

Programmer errors throw at definition/registration time. Runtime conditions return:

type CortexError = {
  code: 'model_call_failed' | 'output_invalid' | 'stopped' | 'aborted' | 'unknown';
  stop?: 'steps' | 'tokens' | 'duration' | 'output_retries' | 'repeated_calls' | 'custom';
  message: string;
  runId: string;
  agentPath: ReadonlyArray<string>;
};

Tool failures are observations, not exceptions — the model sees them and decides; the run fails only on structural conditions.

A tool's input schema validates; it does not convert. It does two jobs: the model is sent its JSON Schema, taken from the schema's output side, and a call's arguments are parsed with it twice — by the loop, then again in front of execute. Converting belongs inside execute, and this is what it costs in the schema:

  • A schema with no JSON Schema (a .transform(), a z.date()) fails the run before the model is asked: code: 'unknown', with Zod's message (Transforms cannot be represented in JSON Schema).
  • A schema whose output is another kind than its input (z.stringbool()) is described to the model by its output and then refuses what its own first parse produced: every call ends in an error observation and execute never runs.
  • A schema that changes a value (.overwrite()) changes it twice. That is harmless where a second pass changes nothing (.trim()), and wrong where it does; the observation's args hold the value as parsed once.

Building

pnpm build        # tsup ESM + CJS + DTS
pnpm test         # vitest run — scripted SignalClient stub, no network
pnpm typecheck    # tsc on src + test

License

Apache-2.0