The stable, public contract for authoring Polyant engine plugins — the tools an AI agent can call. A plugin repo depends on this package and nothing else of the engine's internals.
It is deliberately tiny and stateless: it exposes defineTool + the types a
tool needs. It does not own the tool registry — the engine's loader does — so
your plugin can carry its own copy of this SDK (and its own zod, ai, …)
without any shared-singleton coupling. This is what lets a plugin live in its own
repo, resolve its own dependencies, and be built/bundled independently.
# public git repo — reference by tag (no auth needed)
npm i -D git+https://github.com/polyant-ai/polyant-sdk.git#v1.12.0
npm i zod # peer dependency (you author schemas in zod 4, or zod 3)Your plugin's package.json:
A tool file is tools/<name>.tool.ts and default-exports a defineTool(...):
import { defineTool } from "@polyant-ai/plugin-sdk";
import { z } from "zod";
export default defineTool({
name: "bookAppointment", // becomes "<namespace>:bookAppointment"
description: "Book an appointment in the CRM.", // read by the model: one string
summary: { // read by people in the panel and the marketplace
"en-US": "Books an appointment in the CRM.",
"it-IT": "Prenota un appuntamento nel CRM.",
},
category: "crm",
requiredSecrets: [{ // a field in the tool's panel: give it a title and one sentence
key: "crm_api_key", type: "text",
label: { "en-US": "CRM API key", "it-IT": "Chiave API del CRM" },
description: {
"en-US": "Key of the CRM account the appointments are written to.",
"it-IT": "Chiave dell'account CRM in cui vengono scritti gli appuntamenti.",
},
}],
parameters: z.object({ // STATIC schema (must not depend on ctx)
patientId: z.string(),
date: z.string().describe("ISO 8601"),
}),
execute: async (input, ctx) => { // ctx: instanceId, secrets, audit, state, ai, abortSignal…
const key = ctx.secrets?.crm_api_key;
// ... call your API, use ctx.audit / ctx.state ...
return { status: "booked", id: "..." };
},
});defineTool serializes your static parameters (a zod schema) to JSON
Schema at module load, in your plugin's realm. The engine only ever receives
data (inputSchema) plus your execute function — never a live zod object. That
data boundary is why multiple copies of this SDK / zod across plugins are
harmless. execute(input, ctx) runs your logic; do runtime/semantic validation
inside it and return { error: "..." } rather than throwing.
Polyant shows a plugin to two audiences, and the SDK keeps their text apart.
The model reads description, the parameters schema with its .describe()
strings, and inputExamples. Write them in English, as one string: they are
part of the prompt, and translating them would change how the agent behaves
from one locale to the next.
People read summary (the sentence the panel's tool list and the marketplace
show for the tool), the label and description of each requiredSecrets
field, and the plugin's displayName and description in plugin.json. Each
of these is a LocalizedText: one string per canonical BCP 47 tag, or a plain
string that stands for every locale.
summary: { "en-US": "Books an appointment in the CRM.", "it-IT": "Prenota un appuntamento nel CRM." }The panel shows the reader's tag, else the same language, else English, else
the first tag alphabetically, so a map should carry at least en-US. Tags are
canonical (en-US, it-IT, pt-BR, zh-Hant-TW); defineTool,
defineHook and normalizeRequiredSecrets throw at module load on an empty
text or a tag that is not, so a typo fails your own tests. A tool without
summary is shown with the first line of its description; a hook without
one is shown with its description.
category is an id (crm, email, calendar, files), not text: the panel
translates the ids it knows and humanizes the others, and groups tools that
share one. select choices are stored values and are shown as written.
summary and localized labels need engine 1.3.0 or later, because older
engines expect the labels as strings: declare "engine": ">=1.3.0" in
plugin.json. Dev mode does not send summary; the panel shows a tool served
from a dev session with the first line of its description.
- Use
.nullable(), not.optional()/.default()(apply defaults inexecute). - No
.transform()/.refine()/.preprocess()in the schema — move that logic toexecute. - Avoid
.url()/.email()/.uuid()/.datetime()formats — validate strings inexecute. z.record(z.string(), z.string())is fine;z.record(z.unknown())is not.
A hook file is hooks/<name>.hook.ts and default-exports a defineHook(...).
Unlike a tool, a hook is deterministic lifecycle code — never LLM-invoked,
never in the tool catalog. It runs at one of four conversation events
(conversation_start, message_received, response_generated, response_sent)
and may return a typed control object to influence the turn.
import { defineHook } from "@polyant-ai/plugin-sdk";
export default defineHook({
name: "dirty-output-guard",
description: "Replay the turn when the model emits corrupted output.",
summary: { "en-US": "Replays a turn with corrupted output.", "it-IT": "Ripete un turno con output corrotto." },
mutatesResponse: true, // required to return replaceResponse / regenerate
handler: (ctx) => {
const { text, regenerationCount } = ctx.payload.response!; // response_generated only
if (!isDirty(text)) return; // void = observe only
return regenerationCount < 2
? { regenerate: { reason: "corrupted output" } } // re-run the whole turn
: { replaceResponse: { message: "Sorry, please try again." } };
},
});| Return | Event | Effect |
|---|---|---|
void |
any | observe only (default) |
{ halt: { message, persist? } } |
pre-LLM (conversation_start, message_received) |
skip the LLM, reply with message. persist: false (since v1.5.0) also makes the turn ephemeral — no message rows, trace, state flush or summary/memory work — for command-style hooks whose exchange must stay out of the model's history |
{ injectContext: string } |
pre-LLM | append a one-shot system message to the turn's LLM input |
{ replaceResponse: { message } } |
response_generated |
replace the LLM reply with message |
{ regenerate: { reason? } } |
response_generated |
discard the output and REPLAY the whole turn (system prompt + tools) — since v1.4.0 |
replaceResponse and regenerate require the hook to declare
mutatesResponse: true (the engine then serves that turn non-streamed so the
mutation lands in time). For regenerate, the hook owns the stop condition via
ctx.payload.response.regenerationCount (0 on the first pass); the engine
enforces a hard safety cap. If a pass returns both, regenerate wins.
Caveat: replay re-executes the whole turn, tools included — enable a
regenerate hook only where the turn is side-effect-free or its tools are
idempotent, and note that deterministic (temperature: 0) turns reproduce the
same output and merely exhaust the cap (it suits sporadic output corruption, not
systematic errors).
A tool that needs a short LLM answer (a classification, a summary) asks the engine
instead of building its own client from ctx.apiKeys:
const answer = await ctx.ai?.chat({
system: "Answer yes or no.",
messages: [{ role: "user", content: patientText }],
tier: "fast",
});The call goes through the engine's AI gateway with the agent's provider connection
and the models it admits: it is recorded with its cost, counts towards the spend
cap, and stops when the turn is cancelled or the tool's deadline passes. You pick a
tier, never a provider or a model. Hooks have had the same ctx.ai from the start.
ctx.ai is absent on engines that predate it, so keep a fallback while your plugin
supports them. ctx.abortSignal is the same "nobody waits any more" signal for any
other long work, such as a fetch.
A tool can search, read, write and manage the knowledge base of the agent it is
running in — the same store the panel's Knowledge tab and the core knowledge
tools use. It is absent unless the agent grants it, so handle undefined:
execute: async ({ question }, ctx) => {
const kb = ctx.knowledge;
if (!kb) return { error: "knowledge access is not granted for this agent" };
const hits = await kb.search(question, { limit: 5 });
if (kb.level !== "read") {
const res = await kb.write({ filename: "faq-log.md", content: question });
if (!res.ok) return { error: `could not log the question: ${res.reason}` };
}
return { hits };
}Four things decide what your tool can do, and none of them is negotiable from inside the plugin:
| The agent is implicit | No method takes an agent. The accessor belongs to the turn, so you cannot reach another agent's knowledge base. |
| Presence is the read grant | ctx.knowledge is missing when the agent grants nothing, which is why reads return data directly instead of a status wrapper. |
level says how far you can go |
read / write / manage, monotone. Read it and tell the model what is possible rather than letting it hit a refusal. |
| Ownership is engine-assigned | origin/originRef record who wrote a document. At write you create documents and update the ones you wrote; a panel upload or another plugin's document answers not_owned until the level is manage. |
Reads (search, get, list) reject when the store cannot answer, instead
of returning null/[] — "no such document" and "the database is unreachable"
are opposite facts, and reporting the second as the first makes an agent announce
that no such documentation exists. Catch them if your tool wants to degrade.
Mutations, by contrast, never reject: they answer
{ ok: true, document, created } or { ok: false, reason } —
not_granted, not_owned, not_found, too_large, limit_reached,
unsupported — so a
refusal is something you can hand back to the model, never a thrown turn. Every
call is audited by the engine. There is deliberately no method to erase the
whole knowledge base: wiping an agent's knowledge is a panel operation.
Declare the minimum you need with requiredKnowledge: "read" | "write" | "manage" on defineTool: the engine then does not equip the tool at all when
the agent grants less, exactly as it prunes a tool whose requiredSecrets are
unset — so the model is never offered a tool whose every call would be refused.
Note that a write triggers chunking and embedding with the agent's own
embedder — it costs a provider call, and the documents you write are subject to
the same re-embedding as any other when that embedder changes.
When one tool produces bytes that another tool consumes — a plugin renders a PDF, another plugin uploads it — the two cannot import each other's module. They exchange a handle instead, and the engine keeps the bytes:
// producer
const handle = await ctx.artifacts.put({ buffer: pdf, filename: "quote.pdf", mime: "application/pdf" });
return { artifactHandle: handle };
// consumer, later in the same conversation
const file = await ctx.artifacts.take(artifactHandle);
if (!file) return { error: "the file is no longer available; generate it again" };Always await put and take. In-process they may answer synchronously; in
dev mode they are a round trip to the engine and answer a Promise. Awaiting is
correct for both.
| Bound to the conversation | A handle resolves only in the conversation that produced it. |
| One-shot | A successful take consumes the entry; taking the same handle again answers null. |
| Expires | Every entry lives a short time (ttlMs, capped by the engine). Anything that must outlive the turn belongs in durable storage. |
null does not say why |
Unknown, already taken, expired, or produced in another conversation all answer null, on purpose: a distinguishable "wrong conversation" would reveal whether a handle exists. |
put throws (or rejects) when the engine refuses the payload, for example
because it exceeds the size limit or the store is full. In dev mode an artifact
also has to fit in one dev frame (MAX_DEV_FRAME_BYTES, 1 MiB once
base64-encoded, so roughly 765 KB raw): a larger put rejects locally without
being sent, and a larger take rejects without consuming the artifact.
{
"name": "acme-tools",
"version": "1.0.0",
"engine": ">=1.3.0",
"toolsDir": "tools",
"namespace": "acme",
"displayName": { "en-US": "Acme", "it-IT": "Acme" },
"description": {
"en-US": "Order status and returns from the Acme back office.",
"it-IT": "Stato degli ordini e resi dal gestionale Acme."
}
}| Field | Meaning |
|---|---|
name |
Stable plugin id (also the install dir name under the engine). |
version |
Your plugin's version (independent of the engine). |
engine |
Semver range of engine versions you support; mismatch → the engine skips your plugin with a warning. |
toolsDir |
Dir scanned for *.tool.ts (default tools). |
hooksDir |
Dir scanned for *.hook.ts (default hooks). |
namespace |
Prefix applied to every tool name (<namespace>:<name>). Defaults to name. |
displayName |
Optional, LocalizedText. How the admin panel names the plugin in the agent's Tools section and in the tool picker. Without it the panel humanizes the namespace. |
description |
Optional, LocalizedText. One sentence on what the plugin's tools are for, shown when the picker is browsed by plugin. |
longDescription |
Optional, marketplace, LocalizedText. A few paragraphs shown, expandable, on the plugin's page: inline text, or the path of a .md/.txt file in the repo. Paragraphs are separated by a blank line. |
icon |
Optional, marketplace. Path (inside the repo) to a square PNG, WebP or SVG of at most 32 KiB. Without it the catalogue shows the name's initials. |
category |
Optional, marketplace, LocalizedText. Groups and filters the plugin in the install catalogue. Without it the plugin is listed as uncategorised. |
publisher |
Optional, marketplace. Shown next to the name. Defaults to Exelab at publication. |
documentationUrl |
Optional, marketplace. HTTPS link opened from the plugin's detail in the catalogue. |
oauthProviders |
Optional. OAuth providers your tools use through ctx.oauth, registered into the engine's broker at boot. |
system |
Optional, image-baked plugins only. What the engine's container image must carry for your tools to work; see below. |
Tools are enabled one by one on each agent; the plugin is how they are grouped
and named, never what is switched. A LocalizedText field is either a plain
string or a map of BCP 47 tags (see Text people read, text the model
reads). A map in displayName or
description needs engine 1.3.0 or later: an older engine rejects the
manifest and skips the plugin, so declare "engine": ">=1.3.0" when you use
one. The marketplace fields are read only when the plugin is published; every
engine ignores them in plugin.json, so they never narrow the range.
A plugin that shells out to a binary, links against a system library, or needs an
environment variable pointing at one declares it in system:
"system": {
"apk": ["chromium", "nss"],
"npmGlobal": [],
"env": { "PUPPETEER_EXECUTABLE_PATH": "/usr/bin/chromium-browser" }
}| Field | Meaning |
|---|---|
apk |
Alpine packages installed into the engine's runtime image. Default []. |
npmGlobal |
npm packages installed globally, for the executables they put on PATH. Default []. |
env |
Environment variables (string to string) set in the image, typically pointing at what apk or npmGlobal installed. Default {}. |
The block is read when the engine image is built, never at runtime: the build collects the blocks of every plugin baked into it and installs their union. A plugin whose binary is missing therefore fails on the tool call, not at boot, and an image built without your plugin does not carry what it needs. A declared variable is a default: a value set in the container environment, even an empty one, wins. Two plugins declaring one variable with different values fail the build. Package names must be plain enough to survive an argument list; there is no allowlist, because including a plugin in a build is already the decision to trust it.
system applies only to a plugin baked into the image. A marketplace bundle
cannot carry it: the runtime manifest refuses the key, and publication refuses a
plugin that declares one, since an installed release cannot change the image it
runs in.
A plugin published to the marketplace is installed by an Organization Admin from
inside Polyant, without rebuilding the engine image. Publication runs from the
polyant-marketplace repository (npm run publish-plugin -- <this repo> --ref <tag>),
which builds the runtime bundle, opens the pull request and signs the catalogue
after the merge. Its publishing-plugins skill lists what a plugin must satisfy:
no system block, every npm dependency bundleable into one ESM file (no native
addons), tools and hooks as default exports of *.tool.ts / *.hook.ts, and a
version bumped for each release. The namespace is permanent once published.
defineTool(spec) => ToolDefinition— the tool authoring entry point.defineHook(spec) => HookFunctionDefinition— the hook authoring entry point (deterministic lifecycle code, never LLM-invoked).toJsonSchema(zodSchema)— the zod→JSON-Schema conversiondefineTooluses.normalizeRequiredSecrets,requiredSecretKeys— helpers for the secrets contract.- Tool types:
ToolSpec,ToolDefinition,ToolInfo,ToolInputExample,RequiredSecretSpec,RequiredSecretsInput. - Context types:
ToolContext,InstanceSlug,AuditLogger,Attachment,ChannelStateIdentity,ConversationStateApi,ConversationHistoryApi,ConversationMessage,ConversationRole,RecentMessagesOptions,ToolApiKeys,OAuthAccessApi,OAuthTokenResult,ArtifactApi,ArtifactPayload,AgentAi. - Knowledge types:
KnowledgeApi,KnowledgeAccessLevel,KnowledgeOrigin,KnowledgeDenialReason,KnowledgeSearchHit,KnowledgeSearchOptions,KnowledgeDocumentSummary,KnowledgeDocumentContent,KnowledgeListOptions,KnowledgeWriteResult. - Hook types:
HookSpec,HookFunctionDefinition,HookContext,HookResult,HookEvent,HookEventPayload,HookAi. @polyant-ai/plugin-sdk/dev(separate entry point):serveDevSession,loadToolsFromPaths,loadHooksFromPaths,devHook,isToolDefinition,isHookDefinition,toDeclarations,toHookDeclarations,createCtxProxy,createHookCtxProxy,defaultWebSocketFactory, the ported wire protocol (DEV_PROTOCOL_VERSION,clientFrameSchema,serverFrameSchema,parseServerFrame,serializeClientFrame,CTX_OPS,MAX_DEV_FRAME_BYTES) and its types. See Dev mode above.
@polyant-ai/plugin-sdk/dev is a separate, Node-only entry point (nothing in it is
pulled in by the root import). It connects your local *.tool.ts files to a remote
Polyant agent over the engine's dev bridge: the agent equips them for a turn and calls
their real execute with the real ToolContext — scoped secrets, conversation state,
audit, OAuth, history, knowledge, artifacts. Your code never leaves your machine.
Issue a per-agent token from the agent's Dev tab in the admin panel, then:
import { serveDevSession, loadToolsFromPaths } from "@polyant-ai/plugin-sdk/dev";
const tools = await loadToolsFromPaths(["./src/tools"]);
const session = await serveDevSession({
agentSlug: "acme-bot",
token: process.env.POLYANT_DEV_TOKEN!,
url: "wss://engine.example.com", // /dev-mode/socket is appended when the path is empty
tools,
onEvent: (event) => console.log(event), // the library never prints on its own
});
// later, on a file change:
session.updateTools(await loadToolsFromPaths(["./src/tools"], { cacheBust: true }));
// session.close();Three things to know before using it:
- Run the process with
tsx(or another TypeScript loader) if your tool files are.ts.loadToolsFromPathsonly does a dynamicimport(); registering a loader is a decision about the whole process, so it is the caller's, not this package's. - The default transport is Node's global
WebSocket, which needs Node 22+ — this package takes no new runtime dependency for it. On Node 20/21, inject one:webSocketImpl: (url) => new WS(url)withws. The same seam is how you test a session with no network. - A pending reconnection keeps your process alive. The session is alive until you close
it, so after a dropped connection the runtime holds the event loop while it retries —
session.close()is what lets the process end, andreconnect: falsearms no retry at all. ctx.stateis synchronous, exactly as in-process: reads are served from the snapshot that came with the invocation, and your writes are returned with the result so the engine applies them only when the call succeeds.
A session serves hook functions the same way, with one extra thing to say: a hook is resolved by name rather than equipped by collision, so how it enters the agent's pipeline is declared, not inferred. A hook file says it beside its default export:
import { defineHook } from "@polyant-ai/plugin-sdk";
import { devHook } from "@polyant-ai/plugin-sdk/dev";
export default defineHook({ name: "guard", description: "…", handler: async (ctx) => … });
// Either run in place of a function the agent already has…
export const dev = devHook({ overrides: "acme:greeting" });
// …or run on an event no configured row mentions, for this session only:
export const dev = devHook({ bindTo: { event: "message_received", position: 10 } });import { loadHooksFromPaths, serveDevSession } from "@polyant-ai/plugin-sdk/dev";
const session = await serveDevSession({
agentSlug: "acme-bot",
token: process.env.POLYANT_DEV_TOKEN!,
tools: await loadToolsFromPaths(["./src/tools"]),
hooks: await loadHooksFromPaths(["./src/hooks"]),
});
session.updateHooks(await loadHooksFromPaths(["./src/hooks"], { cacheBust: true }));overrides keeps the configured rows in charge of the event, the position and the
timeout, and only swaps the implementation. bindTo brings its own position and timeout,
and the row it produces lives exactly as long as the session — nothing is written to the
agent's configuration. A bound hook takes a bare name: the engine reads a plugin namespace
off the function name to decide grants, so it refuses a self-declared one.
The context is the real HookContext: state synchronous over a snapshot with write-back,
conversation and ai.chat over RPC, secrets scoped to what the hook declared. The
deadline belongs to the engine — your handler sees it as ctx.abortSignal.
Design record: docs/superpowers/specs/2026-09-03-dev-session-client-runtime-design.md.
The engine-side documentation is docs/dev-mode.md in polyant-enterprise.
This package's version is the plugin compatibility contract. It is referenced
by plugin.json.engine (via the engine version) and consumed by both the engine
and every plugin. Bump deliberately (semver); breaking the tool contract is a
major bump.
Apache License 2.0. This SDK is intentionally permissive so plugin authors can build and ship tools under whatever license they choose — the engine that consumes it is licensed separately.
{ "peerDependencies": { "@polyant-ai/plugin-sdk": "*" }, "devDependencies": { "@polyant-ai/plugin-sdk": "git+https://github.com/polyant-ai/polyant-sdk.git#v1.12.0" }, "dependencies": { "zod": "^4.6.6" /* + any lib your tools call */ } }