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:
- 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
argscan override it. - Tool schema. The toolkit reads each notification’s
data.schema.yamland 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. - Result shaping. The SDK’s
SendMultiResultincludes provider names, raw HTTP status codes, response bodies, andskippedchannels. 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? }. - 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
noncefield can’t bypass it).
Status. The agent toolkit ships in
norq.sh@0.1.2-alphaandnorq@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 aiPython:
pip install norq # core toolkit
pip install 'norq[openai]' # adds the openai extra for the OpenAI Chat Completions adapterQuick 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/slacketc. 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_ERRORcodes (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
triggeredboolean. Forcing the model to read thechannelsarray 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.yamlhint 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" subpath — require("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
argumentsJSON; returns a fixed"invalid arguments JSON"message if malformed (Python’sJSONDecodeErrortext never reaches the LLM). - Recursively compacts
Nonefields before serialization, so eachchannels[]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
executeis called. Wrapdescriptor.executein 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
argsshapes.