# 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.

```typescript
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`** — Is `response_format` accepted together with `tools` in 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

```typescript
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

```typescript
const signal = createSignal('groq', {
  apiKey: 'gsk_...',
  model: 'qwen/qwen3.8-27b',
  systemPrompt: 'You are a data extractor.',
  retries: 3,
});
```

### Custom Provider

```typescript
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.
```typescript
signal.model('qwen/qwen3.8-27b')
```

### `.systemPrompt(prompt)`
Set the system prompt.
```typescript
signal.systemPrompt('You extract structured data from text.')
```

### `.schema(zodSchema)`
Set the output schema. The response will be typed, validated, and retried on failure.
```typescript
signal.schema(z.object({
  name: z.string(),
  age: z.number(),
}))
```

### `.tools(tools)`
Set tools for tool calling. See [Tools](#tools) below.
```typescript
signal.tools([searchTool, calculatorTool])
```

### `.history(messages)`
Set conversation history for multi-turn.
```typescript
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`.
```typescript
signal.retries(3)
```

### `.apiKey(key)`
Override the API key.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
signal.onRetry((error, attempt) => console.log(`Retry ${attempt}:`, error.message))
```

### `.onToolCall(handler)`
Hook called when a tool is executed.
```typescript
signal.onToolCall((name, args) => console.log(`Calling ${name}`, args))
```

---

## Execution

### `.complete(input)`

Execute the request. Returns `{ response, history, meta }`.

```typescript
const { response, history, meta } = await signal.complete('What is 2+2?');
```

`input` can be a string or multimodal content parts:

```typescript
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.

```typescript
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);
}
```

```typescript
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 as `toolCalls` (`{ id, name, args }`, `args` a JSON string) and has `content: ''` when it only called tools. Each result goes back as a `tool` message whose `toolCallId` is that call's `id`.
- **`tools`** — each is `{ name, description, parameters }`; `parameters` is the JSON Schema of the tool's input (`z.toJSONSchema()` of a Zod schema).
- **`output.accept`** — gates and does not transform: `outcome.value` is 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: with `tools` on a model without `toolsWithStructuredOutput`, the provider rejects the request.
- **`toolCalls`** — a call's `args` is the parsed JSON value, or the provider's string when it does not parse. It is not checked against the tool's `parameters`.
- **`usage`** — `reported: false` means 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, without `output.accept`, on a result recovered from a provider rejection.
- **`wire`** — `rung` names what produced the accepted output: `parse` for content that was valid JSON as it arrived, `call-args` for a call's arguments, otherwise the repair it took. `recovered` is set when the result was recovered from a provider rejection.
- **`reasoning` events** — the model's thinking, never part of `content`. Only a provider that streams its reasoning produces them; on Groq and OpenRouter Signal asks for it only when `reasoningEffort` is set.
- **`tool_call_delta` events** — each carries one fragment of a call's arguments (`argsText`) for the call at `index`. The assembled calls are in the `done` event's `toolCalls`.

### `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 the `response_format` under `native` and the exit tool's params under `respond`. An endpoint with `validatesToolArgs` is never sent it as tool params.
- **`looseWire`** — the same contract with the payload left untyped. It replaces `wire` as the exit tool's params when the contract has `data` and `wire` has no JSON Schema, has a `$ref` in it, or is longer than 8,192 characters as one.
- **`hasData`** — whether the contract carries a typed payload (a `data` field).
- **`forceTool`** — every turn must be a tool call: under `respond` the result carries `toolChoice: 'required'`. It makes `auto` resolve to `respond` where it would resolve to `emit`, and throws `E_VALIDATION_FAILED` with an explicit `emit`.

The result:

- **`respondDetail`** — under `respond`, how much of the contract the exit tool's params carry: `full` (`wire`), `loose` (`looseWire`) or `permissive` (the endpoint validates tool args, so the params name the envelope fields and constrain nothing).
- **`respondDescriptor`** — under `respond`, the exit tool to append to the request's `tools`.
- **`outputToolName`** — under `respond`, the exit tool's name (`RESPOND_TOOL_NAME`). Pass it as `output.outputTool`; the `tool_call_delta` events of a call with that name carry the output as it arrives.
- **`responseFormat`** — under `native`, the `responseFormat` to send on every request.
- **`toolChoice`** — `'required'` under `respond` with `forceTool`.
- **`injectSchemaDoc`** — `true` when the contract has `data` and the transport does not carry its full schema (`emit`, and `respond` with `loose` or `permissive` params): 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}` in `invalidOutput` with the evidence.

### `.count(input)`

Rough token count for a string or a message list. A heuristic (about 4 characters per token), not a tokenizer.
```typescript
const tokens = await signal.count('How long is this?');
```

---

## Response

```typescript
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

```typescript
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.

```typescript
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

1. Zod schema is converted to JSON Schema (draft-07)
2. Based on capabilities:
   - `nativeJsonSchema` (and, when tools are set, `toolsWithStructuredOutput`) → sends JSON Schema as `response_format`, with `strict: false`
   - otherwise `nativeJsonMode` and no tools → sends `response_format: json_object` + schema in a system message
   - otherwise → schema in a system message only
3. The response is normalized by the wire layer (JSON extraction and repairs, each counted only if the result passes the schema)
4. Validated with `schema.safeParse()`
5. On failure: the evidence is sent back to the model as a correction message
6. Retries up to `retries` times (default: 2), then fails with `E_VALIDATION_FAILED`
7. Returns typed, validated result

---

## Tools

### Defining Tools

```typescript
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

```typescript
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:

1. Model responds with tool calls
2. Signal validates args with Zod, executes the tool
3. Tool result is sent back to the model
4. Model responds with the final answer
5. 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:

```typescript
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

```typescript
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.

```typescript
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:

```typescript
await embedder.embed('text', { onUsage: (usage, model) => meter(model, usage.totalTokens) });
```

### Similarity

Cosine similarity between embedding vectors measures semantic relatedness:

```typescript
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:

```typescript
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

```typescript
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 the `criteria` keys; `score` → a level index into `criteria`; `noul` → a boolean. A `score` question's `criteria` lists 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_FAILED` and the provider's body under `context.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:

```typescript
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:

```typescript
if (signal.describe().kind === 'decisions') { /* sub-second */ }
```

### Abort

```typescript
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:

```typescript
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 |

```typescript
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_format` there; the schema rides a system message and is validated client-side.
- **`json_validate_failed` errors.** 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 `respond` params would enforce nothing on Groq, and `auto` resolves every agent there to `emit`.

You don't need to handle any of this — Signal does it automatically based on the registry.
