Signal — Design Document
Architecture, design decisions, and trade-offs behind @niscorp/signal.
For usage documentation, see DOCS.md.
One sentence: what comes out conforms to the schema that went in, on every provider — or you get a typed failure with evidence.
The layer map
types.ts the public contract (incl. StepOutcome / Rejection / WireReport)
registry.ts provider DATA (baseUrl, endpoint capabilities, adapter id, wire strategy ids)
+ model DATA (model capabilities, reasoning efforts) — measured rows
scripts/ probe-model.ts: measures one model on one provider, prints its registry row
adapters/ byte movers, one per WIRE PROTOCOL (not per vendor) — throw-and-wrap, zero policy
decide/ gate.ts: the acceptance gate DERIVED from a decide() call's questions
wire/ RESPONSE side: repair.ts (mechanisms) → router.ts (classify) → strategies/ (provider quirks)
transport/ REQUEST side: resolve.ts (respond | native | emit) + protocol.ts (ALL prompt prose)
signal.ts the client: step/stepStream = the ONE execution core, builders, embed, decide, count
run.ts complete()/stream(): one loop over step — runStream is the core, runComplete drains it
The seam rule: wire is the response side, transport is the request
side, and everything between them is step(). A change touching both
sides is two changes.
Placement rules (decided once, checkable in review)
- A vendor is a registry row. Adapters exist per wire protocol; Groq = the openai-compatible adapter + registry data (capabilities, wire strategy ids). Never write a vendor adapter that duplicates a protocol.
- A provider pathology is a wire strategy: one file under
wire/strategies/, fixtures from the real error/response body, id listed on the provider's registry entry. Two hooks:error(recover aRejectionfrom a thrown provider error — Groqfailed_generation) andresponse(contribute candidate texts — 4o JSONL). Adding a quirk changes nothing else. - A pure byte/value transform is a repair mechanism in
wire/repair.ts(extract, escape repair, jsonish decode, truncation close). Mechanisms never validate and never know a provider; the router composes them. - Prompt prose exists in exactly one file:
transport/protocol.ts(finish protocols, corrections, the bare-schema prompt, the exit-tool description). Prompt text anywhere else in signal is a review failure. - Public contract types live in
types.ts; module-internal types live beside their code. No floating type files.
Invariants
- Request immutability. The request shape (tools, toolChoice, responseFormat) never changes during a run. All adaptation is response-side. (Learned twice — mid-run tool masking and toolChoice pinning both confuse models. Never again.)
- Repairs are rescue-only. A repaired candidate counts ONLY when it passes the caller's acceptance schema; otherwise the original bytes and the original error stand. This is why the ladder safely runs on every response of every provider.
- The router owns classification. A response is
tool_calls(declared tool),output(anything whose repaired value passes the acceptance gate — exit-tool args and pseudo-tool args included), orfailedwith evidence (+truncatedwhen bytes end mid-structure). Callers switch on the outcome; they never parse strings. - Rejections are arrivals. A provider 400 that carries the model's attempt is recovered by an error-hook strategy and routed identically: a rejected call to a declared tool IS a tool call; a rejected pseudo-call carrying valid output IS output.
- Transport resolution is pure.
resolveTransport(spec, capabilities)— previews resolve exactly like runs.autopicks what ENFORCES: native when grammar is viable; respond when its tool params can enforce the contract (the endpoint does not validate tool args itself, the full schema fits, the model does not mangle nested args — orforceTool); emit otherwise, because every model can do it and the contract rides the prompt either way. Explicit choices are honored. Permissive respond params on hard-validating providers (validatesToolArgs) never advertise a field the contract lacks. - A capability is owned by the endpoint or by the model, never both.
EndpointCapabilitieslive on the provider row,ModelCapabilitieson a model row keyedprovider/model; a client's capabilities are the join. Model rows are pasted fromscripts/probe-model.ts, carry the day they were measured, and are the only place a model fact lives — no instance override, no app-side table. The invariants aretest/registry.test.ts.
Design decisions (carried from v1 where still true)
- Factory function, not class. Plain object from a closure.
- Immutable builder via spread. Each method forks the config bag.
- String provider names; registry supplies URLs, env keys, endpoint and model capabilities; object config for custom endpoints.
- Capabilities drive behavior, not provider identity. No "if groq then X" outside the registry row.
- Zod is the source of truth. Provider grammar is a compliance hint; the acceptance schema gates what actually counts.
- Zero hard dependencies. SDKs load dynamically; browsers inject a client instead.
- History is external.
complete()returns full history; callers thread it back. step()/stepStream()are the primitives;complete()/stream()are wrappers over them. One pipeline: orchestrators (cortex) and the convenience API ride the same wire layer, the same routing, the same recovery. Streaming is a delivery mode, never a second system.- Streaming validation is end-of-stream; mid-stream structural
parsing is
@niscorp/solid's job in the consumer. - An adapter is one of two kinds, and the kind is the capability.
ChatAdapter | DecisionAdapter, discriminated bykind, and the registry row and the custom-provider config split the same way. A decision model has no chat endpoint, so a decision adapter carrying achatthat throws would be an adapter lying about what it is. There is deliberately nosupportsDecisionsflag: a flag beside the kind is a second truth that can disagree with the first.describe().kindanswers without the network. decide()sits beside the execution core, not in it. Likeembed: one request, one response, no history, tools or loop — none of the wire layer applies, because a decision model writes no bytes to repair. The questions are the schema: the result type and the gate (decide/gate.ts) both derive from them, which is the package's one sentence applied to a model that answers instead of writing.- Emulation is honest or absent. On a chat adapter
decide()runs the derived schema throughcomplete()and returns picks withcalibrated: false— the discriminant ofDecideResult, so the probabilities do not exist in the type on that branch. A fabricated1.0would clear every caller's threshold. Same rule asusage.reported.
Known debt
adapters/anthropic.adapter.tsandgoogle.adapter.tsare stubs that throw while the registry advertises both providers — either the entries go or the adapters get built.- The provider SPI types (
ProviderAdapter,ProviderRequest,ProviderStreamDelta) live intypes.ts; they belong besideadapters/. step/stepStreamshare ~30 lines of routing glue in signal.ts.count()is a chars/4 heuristic.