Signal · model calls

The call to a model.

Signal is the client Nisc calls a model with. You give it a Zod schema and get back an object of that shape. Signal sends the schema the way that model takes one, reads the JSON out of whatever comes back, and checks it against the schema before you get it.

pnpm add @niscorp/signal zod openai · 0.1.5 · Apache-2.0

A first call
import { z } from 'zod';
import { createSignal } from '@niscorp/signal';

const Booking = z.object({
  show: z.string(),
  seats: z.number(),
});

const signal = createSignal('groq');
const { response } = await signal
  .schema(Booking)
  .complete(
    'Two for The Tempest on Saturday, please.',
  );
The model wrote
Here is the booking:
```json
{ "show": "The Tempest", "seats": 2 }
```
You get
{"show":"The Tempest","seats":2}

The model’s replies on this page are written by hand, and no model is called. What Signal did with each reply is what the installed package returned.

First steps →
Why Nisc has it

Every model takes a schema its own way.

One model follows a JSON Schema sent with the request. Another only promises to return JSON. qwen/qwen3.8-27b does the first on OpenRouter and the second on Groq. Signal knows this, so no other Nisc package has to: Cortex calls every model through Signal.

With the requestAs a JSON Schema, to a model that follows one. Signal measured that, or you said so for your own endpoint.
JSON mode, and a messageThe provider is told to return JSON, and the schema is written into a system message.
A message aloneFor an endpoint that does neither. Also for a call with tools, unless the model follows a JSON Schema in a request that has tools.
What comes back

Read out of whatever the model wrote.

A model that was asked for JSON often wraps it in words, escapes its quotes, or stops before the end. Signal tries the reply as it stands, and then looks for the JSON in it. Each of these four replies gave {"show":"The Tempest","seats":2}.

A code fenceHere is the booking: ```json { "show": "The Tempest", "seats": 2 } ```
A sentenceSure. { "show": "The Tempest", "seats": 2 } Anything else?
Escaped quotes{\"show\": \"The Tempest\", \"seats\": 2}
Cut off{ "show": "The Tempest", "seats": 2, "note": "by the ai

What Signal finds counts only if it passes your schema. From a reply that stops early, Signal drops the field that was being written, and this one passed because the schema does not ask for that field. A reply that is valid JSON and fails the schema is retried: the model is sent what failed and asked again, twice by default.

The model wrote, each of three times
{ "show": "The Tempest", "seats": "two" }

The call throws

E_VALIDATION_FAILED · Response failed schema validation: seats: Invalid input: expected number, received string
Which model

Four providers, and any endpoint of your own.

Signal ships 14 rows of what a model can do: one for each model on each provider it was measured on, each written by a probe script and dated the day it ran. describe() reads the row and makes no request. A model with no row still runs, and is taken not to follow a JSON Schema sent with the request.

Groq, OpenAI and OpenRouter are all called with the openai package, which is why it is in the install line.

Tools

Signal runs the loop, or you do.

A tool is a function of yours with a schema for its arguments. With complete and stream, Signal runs the loop: it runs each tool the model asks for and retries an answer that fails your schema. After twelve calls to the model, ten plus the retries, it throws. step is one call to the model and runs nothing. Use it when the messages and the tools are yours to manage, as they are in an agent runtime. Cortex is built on it.

The loop Cortex runs →

A second kind of model

A model that decides.

A decision model writes no text. You give it something to judge and questions with fixed options, and it returns a probability for each option. typesafe is the provider that has one, and decide is the call.

One question about one message
const decider = createSignal('typesafe');

const result = await decider.decide({
  state: 'Two for The Tempest on Saturday, please.',
  questions: {
    wants: {
      type: 'choice',
      instructions: 'What does the member want?',
      criteria: {
        seats: 'Seats for a night',
        refund: 'Money back',
        other: 'Anything else',
      },
    },
  },
});
The result
{
  "calibrated": true,
  "decisions": {
    "wants": {
      "choice": "seats",
      "probabilities": {
        "seats": 0.94,
        "refund": 0.04,
        "other": 0.02
      },
      "confidence": 0.94
    }
  }
}

A chat model can answer the same call. Then calibrated is false, and there are no probabilities.

Asked of a model client

What an engineer asks.

Does it keep the conversation?NoEach result carries its history, and you pass that to the next call.
Does Signal retry a provider error?NoIt retries only an answer that fails your schema. A provider’s error reaches you as a SignalError with the original inside. The openai package makes its own retries.
Does a tool that throws end the call?NoThe model is sent the error as the tool’s result, and may ask again.
Does it work out what a call cost?NoIt reports the tokens of a run, estimated where the provider sends none. The prices are yours to apply.
Is a reasoning effort the model does not take caught before the request?YesFor a measured model: it is refused when the client is made, and the error names the ones it takes.
Can I reach Claude and Gemini?YesThrough openrouter. Neither has a measured row yet, so a schema goes to them in JSON mode and a message.
Can I test without a model?YesHand createSignal a client of your own and any key, and it answers in place of the provider.
Can I read an answer while it is written?Yesstream is the same run as events, and takes an AbortSignal.