Signal Documentation
Complete reference for @niscorp/signal.
Concepts
Stateless Immutable Builder
Signal uses an immutable builder pattern. Every configuration method returns a new instance — the original is never modified. Nothing is sent until an execution method (complete(), stream(), step(), stepStream(), embed(), decide()) is called.
const base = createSignal('groq').systemPrompt('You are helpful.');
// Fork for structured output — base is untouched
const structured = base.schema(z.object({ name: z.string() }));
// Fork for tools — base still untouched
const withTools = base.tools([myTool]);
// Call complete on any instance, any number of times
const r1 = await base.complete('hello');
const r2 = await structured.complete('Extract: Alice');
Provider Abstraction
Signal doesn't care which LLM provider you use. Under the hood, it translates your request to the provider's API format, handles quirks (Groq can't do tools + structured output together), and normalizes the response.
Capabilities
Each provider has a capability profile that Signal uses to pick the right strategy:
nativeTools— Can the provider do tool calling natively?nativeJsonSchema— Can it enforce a JSON Schema on the response?nativeJsonMode— Can it guarantee JSON output?toolsWithStructuredOutput— Isresponse_formataccepted together withtoolsin one request?validatesToolArgs— Does the endpoint validate tool-call arguments server-side and reject the request on a mismatch?manglesNestedToolArgs— Does the model corrupt nested tool-call arguments? For example, a nested array arrives as a JSON string.multimodal— Does it accept image content?supportsEmbedding— Can it convert text to vectors?
You don't need to think about this unless you're overriding defaults.
Creating a Signal
Known Provider
const signal = createSignal('groq');
Reads GROQ_API_KEY from environment automatically. Known providers: 'groq', 'openai', 'openrouter', 'anthropic', 'google', 'typesafe'.
| Provider | Env var | Default model |
|---|---|---|
'groq' |
GROQ_API_KEY |
qwen/qwen3.8-27b |
'openai' |
OPENAI_API_KEY |
gpt-4o |
'openrouter' |
OPENROUTER_API_KEY |
openai/gpt-4o |
'anthropic' |
ANTHROPIC_API_KEY |
claude-sonnet-4-20250514 |
'google' |
GOOGLE_API_KEY |
gemini-2.0-flash |
'typesafe' |
TYPESAFE_API_KEY |
jev-latest |
'anthropic' and 'google' are registry rows whose adapters are not implemented yet: the client builds and describe() answers, but every execution method throws E_PROVIDER_ERROR (E_MISSING_API_KEY first when no key is set; count(), which never reaches the adapter, still works). Reach those models through 'openrouter'.
Known Provider with Options
const signal = createSignal('groq', {
apiKey: 'gsk_...',
model: 'qwen/qwen3.8-27b',
systemPrompt: 'You are a data extractor.',
retries: 3,
});
Custom Provider
const signal = createSignal({
baseUrl: 'https://my-endpoint.com/v1',
apiKey: 'sk-...',
model: 'my-model',
});
Any OpenAI-compatible API works. A custom provider is outside the registry, so it states what it does itself — capabilities (a Partial<Capabilities>; unstated fields are false) and, optionally, wire strategy ids. adapter defaults to 'openai-compatible'.
Builder Methods
Every method returns a new Signal instance.
.model(name)
Override the model for this instance.
signal.model('qwen/qwen3.8-27b')
.systemPrompt(prompt)
Set the system prompt.
signal.systemPrompt('You extract structured data from text.')
.schema(zodSchema)
Set the output schema. The response will be typed, validated, and retried on failure.
signal.schema(z.object({
name: z.string(),
age: z.number(),
}))
.tools(tools)
Set tools for tool calling. See Tools below.
signal.tools([searchTool, calculatorTool])
.history(messages)
Set conversation history for multi-turn.
signal.history(previousMessages)
.retries(count)
Set max validation retries (default: 2). When a response fails Zod validation, Signal feeds the error back to the model and tries again; once the retries are spent the call fails with E_VALIDATION_FAILED.
signal.retries(3)
.apiKey(key)
Override the API key.
signal.apiKey('gsk_...')
.options(opts)
Set LLM options: temperature, maxTokens, topP, stopSequences, seed, signal (an AbortSignal), reasoningEffort. Merged over any options already set. These are rarely needed.
signal.options({ temperature: 0 })
Model defaults
Some models need options their caller does not know to set. modelDefaults (beside modelRegistry in src/registry.ts, exported) holds them, and a request gets them for whatever the caller leaves unset: the caller's options always win. gpt-oss on Groq gets temperature: 1 and topP: 1, OpenAI's sampling for it (at temperature 0 its reasoning repeats one sentence until the budget runs out), and maxTokens: 16384, because Groq ends a completion at 3,072 tokens unless asked for more, and reasoning alone can pass that.
createSignal('groq').model('openai/gpt-oss-120b') // temperature 1, top_p 1, max_completion_tokens 16384
createSignal('groq').model('openai/gpt-oss-120b').options({ maxTokens: 4000 }) // temperature 1, top_p 1, max_completion_tokens 4000
Capabilities — looked up, not configured
What a client can do is the provider's registry row (what the ENDPOINT
does) joined with the model's registry row (what the MODEL does, as
measured by scripts/probe-model.ts). There is no override: a fact about
a model is a row in src/registry.ts. A model with no row resolves to the
conservative UNMEASURED_MODEL, and describe().modelKnown says so.
UNMEASURED_MODEL assumes the least: nativeJsonSchema: false,
toolsWithStructuredOutput: false, manglesNestedToolArgs: true,
multimodal: false.
A custom provider (by baseUrl) declares its own capabilities.
chatProviderEntry(id) returns a chat provider's registry row, and
undefined for an unknown id and for a decision provider.
createSignal('groq').describe()
// { provider: 'groq', model: 'qwen/qwen3.8-27b', kind: 'chat', modelKnown: true, capabilities: {…} }
describe() makes no request and does not need an API key. For a custom
provider modelKnown is false, and model is undefined when none is set.
A reasoningEffort the model's row does not list is refused when the
client is built (SignalError, E_VALIDATION_FAILED), not by a provider 400
mid-run. An unmeasured model is not checked. On OpenRouter a row lists what
the proxy accepts, which is not what the routed model honours: it passes
reasoning_effort through to non-reasoning models too, so an accepted value
does not mean the model reasons.
.onRetry(handler)
Hook called on each validation failure, including the last one that exhausts the retries.
signal.onRetry((error, attempt) => console.log(`Retry ${attempt}:`, error.message))
.onToolCall(handler)
Hook called when a tool is executed.
signal.onToolCall((name, args) => console.log(`Calling ${name}`, args))
Execution
.complete(input)
Execute the request. Returns { response, history, meta }.
const { response, history, meta } = await signal.complete('What is 2+2?');
input can be a string or multimodal content parts:
await signal.complete([
{ type: 'text', text: 'What is in this image?' },
{ type: 'image', source: { type: 'url', url: 'https://...' } },
]);
.stream(input, options?)
The same run as complete(), delivered as it happens. Returns AsyncIterable<StreamEvent<T>>; options.signal (an AbortSignal) aborts it.
for await (const event of signal.stream('Tell me a story')) {
if (event.type === 'text') process.stdout.write(event.text);
if (event.type === 'done') console.log(event.meta.usage);
}
type StreamEvent<T> =
| { type: 'text'; text: string }
| { type: 'tool_start'; name: string; args: unknown }
| { type: 'tool_end'; name: string; result: unknown }
| { type: 'retry'; reason: string; attempt: number }
| { type: 'error'; error: Error; recovered: boolean }
| { type: 'done'; response: T; history: Message[]; meta: SignalMeta };
Schema validation happens at the end of the stream; when the retries are spent the stream ends with an error event (which complete() throws).
.step(request) / .stepStream(request, options?)
The low-level primitives complete() and stream() are built on: one model call, no tool execution, no schema retries. The caller owns the loop. step() takes a StepRequest (messages, tools, toolChoice, output, responseFormat, options) and returns a StepResult (content, toolCalls, usage, finishReason, raw, and — when the request carried output.accept — the routed outcome and wire report). stepStream() yields text, reasoning and tool_call_delta events and a final done carrying the StepResult.
messages— an assistant turn carries the calls it made astoolCalls({ id, name, args },argsa JSON string) and hascontent: ''when it only called tools. Each result goes back as atoolmessage whosetoolCallIdis that call'sid.tools— each is{ name, description, parameters };parametersis the JSON Schema of the tool's input (z.toJSONSchema()of a Zod schema).output.accept— gates and does not transform:outcome.valueis the accepted candidate itself, not the schema's parsed output, so a transform or a default in the schema is not applied.output.outputTool— the name of an exit tool. A call to it is routed as output, never as a tool call.responseFormat— passed to the provider as given.step()does not check it against the capabilities: withtoolson a model withouttoolsWithStructuredOutput, the provider rejects the request.toolCalls— a call'sargsis the parsed JSON value, or the provider's string when it does not parse. It is not checked against the tool'sparameters.usage—reported: falsemeans the provider sent no usage and the numbers are an estimate (about 4 characters per token). A stream does not reliably carry its usage.outcome— also present, withoutoutput.accept, on a result recovered from a provider rejection.wire—rungnames what produced the accepted output:parsefor content that was valid JSON as it arrived,call-argsfor a call's arguments, otherwise the repair it took.recoveredis set when the result was recovered from a provider rejection.reasoningevents — the model's thinking, never part ofcontent. Only a provider that streams its reasoning produces them; on Groq and OpenRouter Signal asks for it only whenreasoningEffortis set.tool_call_deltaevents — each carries one fragment of a call's arguments (argsText) for the call atindex. The assembled calls are in thedoneevent'stoolCalls.
resolveTransport(spec, capabilities)
For a caller that runs its own loop over step(): resolves how an output contract travels — respond (the arguments of an exit tool), native (response_format) or emit (message content) — and returns what to put on each request. The spec:
wire— the full schema of the output contract. It is theresponse_formatundernativeand the exit tool's params underrespond. An endpoint withvalidatesToolArgsis never sent it as tool params.looseWire— the same contract with the payload left untyped. It replaceswireas the exit tool's params when the contract hasdataandwirehas no JSON Schema, has a$refin it, or is longer than 8,192 characters as one.hasData— whether the contract carries a typed payload (adatafield).forceTool— every turn must be a tool call: underrespondthe result carriestoolChoice: 'required'. It makesautoresolve torespondwhere it would resolve toemit, and throwsE_VALIDATION_FAILEDwith an explicitemit.
The result:
respondDetail— underrespond, how much of the contract the exit tool's params carry:full(wire),loose(looseWire) orpermissive(the endpoint validates tool args, so the params name the envelope fields and constrain nothing).respondDescriptor— underrespond, the exit tool to append to the request'stools.outputToolName— underrespond, the exit tool's name (RESPOND_TOOL_NAME). Pass it asoutput.outputTool; thetool_call_deltaevents of a call with that name carry the output as it arrives.responseFormat— undernative, theresponseFormatto send on every request.toolChoice—'required'underrespondwithforceTool.injectSchemaDoc—truewhen the contract hasdataand the transport does not carry its full schema (emit, andrespondwithlooseorpermissiveparams): the caller adds its own schema documentation to the prompt.finishProtocol— the text that tells the model how the run ends under this transport. Add it unchanged as a system message, after any schema documentation.corrections— the messages to send back when a turn did not finish (termination) or its output was invalid (invalidOutput). The caller replaces{issues}ininvalidOutputwith the evidence.
.count(input)
Rough token count for a string or a message list. A heuristic (about 4 characters per token), not a tokenizer.
const tokens = await signal.count('How long is this?');
Response
type SignalResult<T> = {
response: T; // string if no schema, typed if schema set
history: Message[]; // full conversation including tool calls
meta: SignalMeta; // usage, timing, tool calls, provider details
};
Meta
type SignalMeta = {
model: string; // actual model used
usage: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
durationMs: number; // wall clock time
retries: number; // validation retries that occurred
toolCalls: ToolCallRecord[]; // tools called, args, results, timing
provider: {
raw: unknown; // null: these calls read the reply as a stream, which leaves no body to keep (an array, one entry per call, when the run took several)
errors: ProviderError[]; // currently always empty
};
};
step() returns the provider's response body as its raw. Where a provider rejected a call and Signal recovered the attempt from the rejection, that call's raw is the provider's error.
Structured Output
Pass a Zod schema via .schema(). The response is parsed, validated, and typed.
const { response } = await createSignal('groq')
.schema(z.object({
name: z.string(),
age: z.number(),
city: z.string(),
}))
.complete('Extract: Alice is 30 years old and lives in Berlin.');
// response.name === 'Alice'
// response.age === 30
// response.city === 'Berlin'
How It Works
- Zod schema is converted to JSON Schema (draft-07)
- Based on capabilities:
nativeJsonSchema(and, when tools are set,toolsWithStructuredOutput) → sends JSON Schema asresponse_format, withstrict: false- otherwise
nativeJsonModeand no tools → sendsresponse_format: json_object+ schema in a system message - otherwise → schema in a system message only
- The response is normalized by the wire layer (JSON extraction and repairs, each counted only if the result passes the schema)
- Validated with
schema.safeParse() - On failure: the evidence is sent back to the model as a correction message
- Retries up to
retriestimes (default: 2), then fails withE_VALIDATION_FAILED - Returns typed, validated result
Tools
Defining Tools
import { defineTool } from '@niscorp/signal';
import { z } from 'zod';
const searchTool = defineTool({
name: 'search',
description: 'Search for information',
input: z.object({
query: z.string().describe('Search query'),
}),
execute: async ({ query }) => {
const results = await mySearchApi(query);
return results;
},
});
The Zod input schema validates the model's arguments before execute runs. The return value is stringified and sent back to the model. A call to an unknown tool, arguments that fail the schema, and an execute that throws do not fail the run: each goes back to the model as an error: … tool result (and is recorded in meta.toolCalls).
The model is sent the input schema as JSON Schema, taken from the schema's output side, so an input schema validates and does not convert. One with no JSON Schema (a .transform(), a z.date()) rejects the call before any request is made, with Zod's own error (Transforms cannot be represented in JSON Schema) rather than a SignalError. One whose output is another kind than its input (z.stringbool()) is described to the model by its output: told boolean, a model that sends one gets input_invalid back, and it is the string ("true") that is accepted. Convert inside execute.
Using Tools
const { response, meta } = await createSignal('groq')
.tools([searchTool])
.schema(z.object({ answer: z.string() }))
.complete('Find the current weather in Berlin');
// meta.toolCalls shows what was called
// response.answer contains the final answer
How Tool Calling Works
Tools use the provider's native tools parameter and tool_calls response; the model decides when to call them. A provider without nativeTools cannot take tools — the call throws E_PROVIDER_ERROR. Signal handles the loop:
- Model responds with tool calls
- Signal validates args with Zod, executes the tool
- Tool result is sent back to the model
- Model responds with the final answer
- With a schema set, the final answer is validated against it
Where the model cannot combine response_format with tools (toolsWithStructuredOutput: false, e.g. Groq), the schema rides a system message instead and is validated client-side. A run that is still calling tools after 10 tool turns (plus the retry budget) fails with E_MAX_ITERATIONS.
Multi-Turn Conversations
Signal is stateless — it doesn't track conversation history. The caller owns history and threads it back:
const signal = createSignal('groq');
const r1 = await signal.complete('My name is Alice.');
const r2 = await signal.history(r1.history).complete('What is my name?');
// r2.response contains "Alice"
complete() returns history which includes all messages (system, user, assistant, tool calls). Pass it back via .history() for the next turn.
Embedding
Convert text to dense vectors for similarity search, RAG, clustering, and classification.
Basic Usage
const embedder = createSignal('openai').model('text-embedding-3-small');
// Single text → single vector
const vector = await embedder.embed('wireless headphones');
// vector: number[] (1536 dimensions by default)
// Batch — single API call
const vectors = await embedder.embed([
'wireless headphones',
'bluetooth earbuds',
'industrial pump manual',
]);
// vectors: number[][] (3 vectors)
Dimensions
Some models support output truncation. Smaller vectors are faster to store and compare.
const small = await embedder.embed('text', { dimensions: 256 });
// small: number[256]
Usage
embed() returns vectors only. To read what the call cost, pass onUsage — called after a successful call with the provider's usage and the model that resolved:
await embedder.embed('text', { onUsage: (usage, model) => meter(model, usage.totalTokens) });
Similarity
Cosine similarity between embedding vectors measures semantic relatedness:
const cosine = (a: number[], b: number[]) => {
const dot = a.reduce((s, x, i) => s + x * b[i], 0);
const mag = (v: number[]) => Math.sqrt(v.reduce((s, x) => s + x * x, 0));
return dot / (mag(a) * mag(b));
};
const [a, b] = await embedder.embed([
'comfortable running shoes',
'athletic footwear for jogging',
]);
const similarity = cosine(a, b);
// ~0.92 — semantically similar
Provider Support
embed() does not check supportsEmbedding: the flag is what describe() reports, and the request goes to the provider's embeddings endpoint either way. Of the known providers only OpenAI is marked as having one (text-embedding-3-small, text-embedding-3-large). An endpoint that refuses the request surfaces as E_PROVIDER_ERROR (Embedding error: …, the provider's own error under context.raw). A decision provider and the two adapters that are not implemented fail before any request is made.
Use a separate Signal client for embedding — embedding models are different from chat models:
const chat = createSignal('openrouter', { model: 'qwen/qwen3.8-27b' });
const embed = createSignal('openai', { model: 'text-embedding-3-small' });
Decisions
A decision model takes a state and typed questions and answers each with a pick and a probability distribution. It generates no text, so decide() is a sibling of embed(): one request, one response — no messages, no history, no tools.
Basic Usage
const jev = createSignal('typesafe');
const result = await jev.decide({
state: { message: 'I was charged twice for order 7429', verified: true },
questions: {
intent: {
type: 'choice',
instructions: 'What is the customer asking for?',
criteria: { refund: 'Money back', bug: 'A defect report', other: 'Anything else' },
},
needsHuman: { type: 'noul', instructions: 'Does this need a person?' },
urgency: { type: 'score', instructions: 'How urgent is it?', criteria: ['Low', 'Medium', 'High'] },
},
});
result.decisions.intent.choice; // 'refund' | 'bug' | 'other'
result.decisions.needsHuman.answer; // boolean
result.decisions.urgency.level; // 0 | 1 | 2 — index into criteria
The Questions Are the Schema
You declare the questions once and never write an output type. Signal derives both halves from them:
- The result type.
choice→ the union of thecriteriakeys;score→ a level index intocriteria;noul→ a boolean. Ascorequestion'scriterialists its levels lowest first. - The acceptance gate. The same derivation at runtime: a choice outside the asked options, a question left unanswered, a distribution over other keys, a probability outside [0, 1] — each fails the call with
E_VALIDATION_FAILEDand the provider's body undercontext.raw.
There is no repair and no correction retry. A decision model writes no bytes to repair and cannot be told what it got wrong.
Calibrated or Not
result.calibrated is the discriminant. Probabilities exist in the type only where a model produced them:
if (result.calibrated) {
result.decisions.intent.probabilities; // Record<'refund' | 'bug' | 'other', number>
result.decisions.intent.confidence; // 0–1
result.decisions.needsHuman.noul; // probability of yes
result.decisions.urgency.score; // probability-weighted level, e.g. 1.05
result.decisions.urgency.probabilities; // number[], one per level
}
On a decision provider level is the most probable level and answer is noul >= 0.5.
On a chat provider decide() still works: the same questions run through structured output (native grammar where it exists, the repair ladder, correction retries) and come back as picks alone with calibrated: false. Signal does not invent a 1.0 — it would clear every threshold. Emulation is one to two orders of magnitude slower; a caller on a latency budget checks first:
if (signal.describe().kind === 'decisions') { /* sub-second */ }
Abort
const controller = new AbortController();
jev.decide({ state, questions, options: { signal: controller.signal } });
controller.abort(); // tears down the HTTP request
Provider Support
A provider is one of two kinds. A decision provider (typesafe) has decide() and nothing else — complete, stream, step, stepStream and embed fail with E_VERB_NOT_SUPPORTED before any request is built. A chat provider has every verb, decide() included, by emulation.
typesafe's default model jev-latest is an alias for the provider's current version; meta.model is the version the provider reports having answered. A caller that records calibration against a version pins it with .model('jev-1.13.0').
A decision provider by base URL — self-hosted, or the fake a check runs — names its protocol:
const local = createSignal({ baseUrl: 'http://127.0.0.1:8790/v1', apiKey: 'dev', model: 'fake', adapter: 'systemone' });
meta.usage.reported is false when the provider sent no usage, and always false under emulation: complete() sums reported and estimated usage without saying which.
Error Handling
All errors are SignalError instances with a .code and optional .context:
| Code | When |
|---|---|
E_PROVIDER_NOT_FOUND |
Unknown provider string |
E_MISSING_API_KEY |
No API key found (env or explicit) |
E_MISSING_MODEL |
No model specified for custom provider |
E_MISSING_SDK |
Provider SDK not installed |
E_VALIDATION_FAILED |
Response failed Zod validation after all retries; a decide() answer failed its gate; a reasoningEffort the model does not take |
E_MAX_ITERATIONS |
The tool loop did not finish within its turn limit |
E_PROVIDER_ERROR |
Provider API error (rate limit, server error, a refused embed(), etc.); tools on a provider without nativeTools; an adapter that is not implemented |
E_MAX_RETRIES, E_TOOL_NOT_FOUND, E_TOOL_EXECUTION, E_TOOL_VALIDATION |
Declared in ErrorCode, not currently thrown — tool failures go back to the model as tool results, and exhausted retries are E_VALIDATION_FAILED |
E_VERB_NOT_SUPPORTED |
A chat verb was called on a decision provider |
import { SignalError } from '@niscorp/signal';
try {
await signal.complete('...');
} catch (error) {
if (error instanceof SignalError) {
console.log(error.code, error.context);
}
}
Groq Specifics
Groq is fast and cheap but has quirks:
- No native tools + structured output together. With tools set, Signal sends no
response_formatthere; the schema rides a system message and is validated client-side. json_validate_failederrors. Groq sometimes returns the model's failed output in the error. Signal extracts and recovers from this.- Model-dependent capabilities. What each Groq model does (json_schema, nested tool args, reasoning efforts) is its own row in the model registry, measured by the probe.
- Tool args are validated server-side. So
respondparams would enforce nothing on Groq, andautoresolves every agent there toemit.
You don't need to handle any of this — Signal does it automatically based on the registry.