Norq

Agent Toolkit

The Agent Toolkit ships in both the Node.js (norq.sh) and Python (norq) SDKs. It turns each notification in your project into a typed tool an LLM can call, while keeping the bits an LLM should never touch — who the message goes to, and what the provider returned — out of the model’s reach by construction.

Why use it

Calling Norq from an agent without the toolkit means hand-rolling four things you’d otherwise get for free:

  1. Recipient binding. You don’t want the model picking the email address. The toolkit binds the recipient at construction time; nothing the LLM emits as args can override it.
  2. Tool schema. The toolkit reads each notification’s data.schema.yaml and derives the JSON Schema the LLM sees — so the model knows what fields to fill in, and your existing schema is the single source of truth.
  3. Result shaping. The SDK’s SendMultiResult includes provider names, raw HTTP status codes, response bodies, and skipped channels. None of that helps the model and some of it (provider response bodies) is risky to surface. The toolkit summarizes every dispatch into a small, allowlisted shape — { notificationId, idempotencyKey, channels[], error? }.
  4. Idempotency. Models retry. Prompt-injection loops re-emit the same call. Without a stable idempotency key, every retry actually re-sends. The toolkit auto-generates a deterministic key per call (and projects args through the schema first, so a prompt-injected nonce field can’t bypass it).

Status. The agent toolkit ships in norq.sh@0.1.2-alpha and norq@0.1.2-alpha. Both SDKs are pre-publish — see norq.sh for release status.

Install

The toolkit is bundled in both SDKs — no extra install for the core. Framework adapters are optional peer/extra deps you only install if you use them.

Node:

npm install norq.sh
# Optional: Vercel AI SDK adapter
npm install ai

Python:

pip install norq            # core toolkit
pip install 'norq[openai]'  # adds the openai extra for the OpenAI Chat Completions adapter

Quick start

Both SDKs have the same shape: build a toolkit with a project directory, a bound recipient, and an allowlist of notifications; pass the resulting descriptors to your LLM framework.

TypeScript — Vercel AI SDK v6

// app/api/chat/route.ts (Next.js / Hono / etc.)
import { createNorqToolkit } from "norq.sh";
import { toAiSdkTools } from "norq.sh/ai-sdk";   // subpath — only loaded when `ai` is installed
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
 
const toolkit = await createNorqToolkit({
  projectDir: process.env.NORQ_PROJECT_DIR ?? "./",
  defaultRecipient: { email: currentUser.email },
  notifications: {
    expose: [
      "transactional/welcome",
      "transactional/account/*",
      "system/billing/usage-*",
    ],
  },
});
 
export async function POST(req: Request) {
  const { messages } = await req.json();
  const result = await generateText({
    model: openai("gpt-5"),
    tools: toAiSdkTools(toolkit.descriptors),
    messages,
  });
  return Response.json(result);
}

Python — OpenAI Chat Completions

# app/agent.py (FastAPI / Flask / etc.)
import asyncio
import os
 
from openai import OpenAI
from norq import create_norq_toolkit
from norq.agent_toolkit.adapters.openai_tools import (
    handle_tool_call,
    to_openai_tools,
)
 
toolkit = create_norq_toolkit(
    project_dir=os.environ.get("NORQ_PROJECT_DIR", "."),
    default_recipient={"email": current_user.email},
    expose=[
        "transactional/welcome",
        "transactional/account/*",
        "system/billing/usage-*",
    ],
)
 
openai_client = OpenAI()
 
 
async def chat(messages: list[dict]) -> str:
    while True:
        resp = openai_client.chat.completions.create(
            model="gpt-5",
            messages=messages,
            tools=to_openai_tools(toolkit.descriptors),
        )
        msg = resp.choices[0].message
        messages.append(msg.model_dump())
        if not msg.tool_calls:
            return msg.content or ""
        for tool_call in msg.tool_calls:
            tool_msg = await handle_tool_call(toolkit, tool_call.model_dump())
            messages.append(tool_msg)

What the LLM sees

Tool listings derive directly from your notification schemas. For expose: ["transactional/welcome"], the model sees a single tool:

{
  "name": "send_transactional_welcome",
  "description": "Send the 'transactional/welcome' notification. The recipient is bound by the application; provide only the data the template references.",
  "parameters": {
    "type": "object",
    "required": ["user", "action_url", "unsubscribe_url"],
    "properties": {
      "user": {
        "type": "object",
        "required": ["first_name", "email"],
        "properties": {
          "first_name": { "type": "string" },
          "email":      { "type": "string", "format": "email" },
          "tier":       { "type": "string", "enum": ["free", "pro", "enterprise"] }
        }
      },
      "action_url":      { "type": "string", "format": "uri" },
      "unsubscribe_url": { "type": "string", "format": "uri" }
    }
  }
}

The tool name is derived from the notification id by replacing / and - with _ and prepending send_. Schemas pass through verbatim except $schema and description are stripped (the description is carried on the descriptor, separately).

If the schema declares its own description, it becomes the descriptor’s description verbatim. Otherwise the toolkit emits a fallback that names the notification id and reminds the model the recipient is bound — both signals the model needs to pick the right tool and not waste tokens trying to specify a recipient.

Tool result shape

Every successful or failed tool call returns the same minimal shape:

{
  notificationId: string;
  idempotencyKey: string;        // always set; auto-generated deterministic hash by default
  channels: Array<{
    channel: string;             // "email" | "sms" | "slack" | "push" | "whatsapp" | "msteams"
    ok: boolean;
    messageId?: string;          // present iff ok === true
    reason?: string;             // present iff ok === false (allowlisted code, never a free-form string)
  }>;
  error?: string;                // ONLY set when channels === [] (dispatch never reached the send pipeline)
}

Python uses snake_case (notification_id, idempotency_key, message_id) for the dataclass fields, but the JSON shape OpenAI’s adapter emits matches the snake_case form too.

Cases

All channels succeeded:

{
  "notificationId": "transactional/welcome",
  "idempotencyKey": "9f8a…hex",
  "channels": [
    { "channel": "email", "ok": true, "messageId": "msg-1" }
  ]
}

Partial success (1+ succeeded, others failed):

{
  "notificationId": "transactional/welcome",
  "idempotencyKey": "9f8a…hex",
  "channels": [
    { "channel": "email", "ok": true,  "messageId": "msg-1" },
    { "channel": "sms",   "ok": false, "reason": "INVALID_NUMBER" }
  ]
}

The model sees both — so it can correctly tell the user “email sent, SMS couldn’t reach you (invalid number)” instead of glossing over the failure. There’s no top-level triggered boolean; the model derives “did anything succeed” from channels.some(c => c.ok), which forces it to actually look at the array.

All channels failed:

{
  "notificationId": "transactional/welcome",
  "idempotencyKey": "9f8a…hex",
  "channels": [
    { "channel": "email", "ok": false, "reason": "PROVIDER_REJECTED" },
    { "channel": "sms",   "ok": false, "reason": "INVALID_NUMBER"     }
  ]
}

Per-channel reason codes carry the detail; no top-level error.

Dispatch never ran (toolkit guard, validator error, or thrown exception):

{
  "notificationId": "transactional/welcome",
  "idempotencyKey": "9f8a…hex",
  "channels": [],
  "error": "validator/missing-required:action_url,unsubscribe_url"
}

error is mutually exclusive with channels having entries.

What’s deliberately NOT in the result

  • No recipient. Even the bound recipient is never echoed.
  • No provider name. sendgrid / twilio / slack etc. don’t surface.
  • No raw HTTP body. Provider response bodies — including HTML error pages, internal request IDs, and reflected payloads — are redacted to HTTP_4XX / HTTP_5XX / PROVIDER_ERROR codes (Python) or filtered through an allowlist (TS).
  • No skipped channels. The recipient might lack a phone number — that’s about the recipient, not actionable for the model.
  • No top-level triggered boolean. Forcing the model to read the channels array prevents partial-success blindness.

Configuration

createNorqToolkit(config, deps?) — TypeScript

const toolkit = await createNorqToolkit({
  projectDir: "./my-project",
  defaultRecipient: { email: currentUser.email },
  notifications: { expose: ["transactional/*"] },
 
  // Optional — override the deterministic idempotency-key generator.
  idempotencyKey: (ctx) => `${userId}:${ctx.notificationId}`,
}, {
  // Optional — inject a Norq client (testing, custom logger, shared connection pool).
  client: customNorq,
});
Field Type Description
projectDir string Path to the directory containing norq.config.yaml. Required.
defaultRecipient Recipient Bound recipient. The LLM never picks who to send to — this is the toolkit’s security invariant.
notifications.expose string[] | "*" Allowlist. Exact ids, wildcards, or bare "*". See Allowlist patterns.
idempotencyKey (ctx) => string Override the default generator. The context includes notificationId, recipient, args (raw), and schema.

create_norq_toolkit(...) — Python

toolkit = create_norq_toolkit(
    project_dir="./my-project",
    default_recipient={"email": current_user.email},
    expose=["transactional/*"],
 
    # Optional — override the deterministic idempotency-key generator.
    idempotency_key=lambda ctx: f"{user_id}:{ctx.notification_id}",
 
    # Optional — inject a Norq client.
    client=custom_norq,
)

Allowlist patterns

notifications.expose (TS) / expose (Python) accepts an array of patterns or the bare string "*".

Pattern Matches
"transactional/welcome" Exact id only.
"transactional/*" Every notification under transactional/, including nested (transactional/account/security-alert).
"*/security-alert" Any notification ending in security-alert.
"system/billing/usage-*" Glob-suffix match.
"*" Every notification with a data.schema.yaml.

Schemaless handling differs by intent

  • Exact id pointing at a schemaless notification: throws with a data.schema.yaml hint at construction time. (Exact ids are an assertion that the notification exists and is callable.)
  • Wildcard matching only schemaless notifications: also throws (zero eligible matches). Catches typos like transcational/*.
  • Wildcard matching both schemaless and schema-having: silently skips the schemaless ones, exposes the rest.

Empty allowlist throws

expose: [] raises an actionable error at construction. An empty allowlist almost always means a misconfiguration — the model gets a toolkit with no tools and complains “I can’t help with that,” far worse than a loud failure at startup.

Recipient locking

The bound defaultRecipient is the toolkit’s load-bearing security invariant. The dispatcher passes to=<bound recipient> to client.send regardless of what the model emits as args. Try this prompt-injected call:

await toolkit.descriptors[0].execute({
  recipient: { email: "evil@bad.com" },     // ignored
  to:        "evil2@bad.com",                // ignored
  email:     "evil3@bad.com",                // ignored
  user:      { first_name: "Alice", email: "alice@example.com" },
  action_url:      "https://x",
  unsubscribe_url: "https://y",
});

The send goes to whatever was bound at construction. The malicious fields still flow through into data (the toolkit doesn’t filter args — that’s the SDK’s strict validator’s job), but the dispatcher overrides to with the bound value. The LLM-facing result also strips them — none of evil@bad.com, evil2@bad.com, the bound recipient, or the provider name appear in the JSON the model sees.

Idempotency keys

By default the toolkit auto-generates a deterministic key per descriptor.execute(args) call:

sha256(notificationId | canonicalStringify(recipient) | canonicalStringify(schemaNormalize(args, schema)))

The args are projected through the JSON Schema before hashing — only properties declared under schema.properties (and their declared sub-fields, recursively) feed the hash. Unknown top-level keys are dropped before hashing. This is load-bearing: a prompt-injected nonce / _n / __retry field would otherwise change the key and silently bypass the SDK’s dedup. Identical legitimate re-invocations still yield the same key, so the SDK dedupes accidental retries (prompt-injection loops, model-internal retries).

The canonical serialization (sorted keys, no whitespace, raw UTF-8) is identical across the Node and Python toolkits, so the same (notificationId, recipient, args) triple produces the same digest in either SDK — useful when one service in your stack invokes the tool from Node and another calls the same notification from Python and they share a dedup store. Integer-valued floats (e.g. 1.0, -0.0) are normalized to integer form on the Python side to match Node’s JSON.stringify (which emits "1" and "0") before hashing, so a type: "number" schema field whose LLM-emitted value happens to be a whole number still hashes the same on both SDKs. Pinned by fixtures/agent-toolkit-snapshot/idempotency-vectors.json and regenerated via scripts/regenerate-idempotency-vectors.mjs.

The key is never a tool parameter — the LLM doesn’t see it as input, can’t manipulate it directly, and (per the schema-normalization above) can’t manipulate it indirectly by emitting unknown args either.

The key is always surfaced in ToolCallResult.idempotencyKey so your application can correlate to its own audit log.

Override the generator

Use a custom factory to scope dedup more strictly (per-request nonce) or more loosely (per-conversation key). Custom factories receive the raw args (not the normalized projection) plus the schema, so they can choose their own normalization policy.

const toolkit = await createNorqToolkit({
  projectDir: "./",
  defaultRecipient: { email: currentUser.email },
  notifications: { expose: ["*"] },
  idempotencyKey: (ctx) => {
    // Per-request nonce: every call gets its own key, no dedup ever.
    return `${ctx.notificationId}:${requestId}:${Date.now()}`;
  },
});
def per_conversation_key(ctx):
    # One key per conversation — every call within the same chat dedupes.
    return f"{conversation_id}:{ctx.notification_id}"
 
toolkit = create_norq_toolkit(
    project_dir="./",
    default_recipient={"email": current_user.email},
    expose=["*"],
    idempotency_key=per_conversation_key,
)

Required-field guard

Before calling the SDK, the toolkit checks that every top-level required property declared by the schema is present in the args. If anything is missing, it short-circuits with a stable error code and never reaches the SDK:

{
  "notificationId": "transactional/welcome",
  "idempotencyKey": "9f8a…hex",
  "channels": [],
  "error": "validator/missing-required:action_url,unsubscribe_url"
}

The error format is validator/missing-required:<comma-separated-keys> — stable enough that a downstream agent can parse it and re-prompt the model with the missing fields named.

The guard is top-level only. Nested-object validation falls through to the SDK’s strict validator (which is template-path-based, not schema-based — it catches “the template references {{ user.address.city }} but user.address is missing”). The toolkit’s contract is: if you omit a top-level required field, you get a stable code; for anything else, you get whatever the SDK reports.

Framework adapters

The adapters are thin converters from the toolkit’s descriptors into framework-native tool types. The execute function on each adapter delegates straight back to descriptor.execute, so the toolkit’s recipient lock, idempotency, required-field guard, and result minimization all apply uniformly.

Vercel AI SDK v6 (TypeScript)

import { toAiSdkTools } from "norq.sh/ai-sdk";
 
const tools = toAiSdkTools(toolkit.descriptors);
// tools is Record<string, Tool> keyed by descriptor name.

The adapter is only reachable via the "norq.sh/ai-sdk" subpathrequire("norq.sh") does NOT pull it. This is because the adapter does import { tool, jsonSchema } from "ai", and ai is an optional peer dep. If we re-exported the adapter from the root entry, every consumer who hadn’t installed ai would get MODULE_NOT_FOUND. Use the subpath import; the toolkit core is at the root.

OpenAI Chat Completions (Python)

from norq.agent_toolkit.adapters.openai_tools import (
    to_openai_tools,
    handle_tool_call,
)
 
tools = to_openai_tools(toolkit.descriptors)
# tools is List[Dict] in OpenAI's tool-call shape.
 
# Inside your message loop:
for tool_call in msg.tool_calls:
    tool_msg = await handle_tool_call(toolkit, tool_call.model_dump())
    messages.append(tool_msg)  # role=tool, content=<JSON of ToolCallResult>

handle_tool_call:

  • Looks up the descriptor by name; returns {"error": "unknown tool '<name>'"} if missing.
  • Parses arguments JSON; returns a fixed "invalid arguments JSON" message if malformed (Python’s JSONDecodeError text never reaches the LLM).
  • Recursively compacts None fields before serialization, so each channels[] entry carries {ok, message_id} OR {ok, reason} — never null fillers.

Error code reference

The toolkit uses a small allowlisted vocabulary to keep error and per-channel reason strings predictable and never leaky.

Top-level error codes

Set only when channels === [].

Code When
validator/missing-required:<keys> Toolkit’s required-field guard fired before dispatch. <keys> is comma-separated.
NO_ELIGIBLE_CHANNELS The SDK returned a SendMultiResult with no channel attempts (recipient lacked every channel the notification declares).
DISPATCH_FAILED An exception propagated through the SDK that didn’t carry an allowlisted code. The exception’s message is never surfaced.
validator/<slug>, provider/<slug> Author-curated typed errors (e.g. validator/missing-data, provider/cli-http2-required). The slug shape is treated as stable code.

Per-channel reason codes

Set on entries with ok: false.

Code When
INVALID_NUMBER SMS provider rejected the number.
INVALID_TOKEN Push token classified as invalid (FCM UNREGISTERED, APNs BadDeviceToken, Web Push 404/410).
PROVIDER_ERROR Generic provider failure with no recognizable error code. The catch-all.
PROVIDER_REJECTED Provider returned a structured rejection (e.g. SendGrid 401).
HTTP_4XX / HTTP_5XX (Python only) The Python SDK’s executor stringifies HTTP failures; the toolkit collapses them to a status-class code and discards the response body.
validator/<slug>, provider/<slug> As above.

Anything else collapses to PROVIDER_ERROR — the allowlist is the contract.

Cross-language parity

Both SDKs produce byte-equal descriptor JSON for the same project, recipient, and expose config. The repository ships a canonical snapshot at fixtures/agent-toolkit-snapshot/expected.json that both SDKs deep-equal-assert against, so any drift between TypeScript and Python is caught immediately. If you find a difference, file an issue with the expose pattern and the fixture.

What’s NOT in the toolkit (yet)

These are deliberate omissions, not gaps:

  • MCP runtime adapter. Norq has an MCP server (norq mcp-server) for authoring templates from AI assistants — it’s separate from this toolkit, which is for runtime sending from agent applications. See AI Integration.
  • LangChain, Mastra, OpenAI Agents SDK, PydanticAI. Follow-up adapters; the descriptor shape is small and language-native enough that a hand-rolled adapter is ~10 lines if you need one before official support lands.
  • Hot-reload. Toolkit construction enumerates notifications once. Restart the process (or rebuild the toolkit) to pick up new notifications.
  • Human-in-the-loop confirmation. The toolkit always dispatches when execute is called. Wrap descriptor.execute in your own approval flow if you need a confirm step before sending.
  • Codegen integration. The toolkit reads schemas at runtime; it doesn’t yet emit typed bindings for the per-notification args shapes.