Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

@polyant-ai/plugin-sdk

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.

Install

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

{
  "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 */ }
}

Authoring a tool

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: "..." };
  },
});

The contract in one paragraph

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.

Text people read, text the model reads

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.

Rules for parameters (OpenAI strict-mode compatible)

  • Use .nullable(), not .optional() / .default() (apply defaults in execute).
  • No .transform() / .refine() / .preprocess() in the schema — move that logic to execute.
  • Avoid .url()/.email()/.uuid()/.datetime() formats — validate strings in execute.
  • z.record(z.string(), z.string()) is fine; z.record(z.unknown()) is not.

Authoring a hook

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." } };
  },
});

HookResult — how a hook influences a turn

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

Asking a model from a tool (ctx.ai)

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.

The agent's knowledge base (ctx.knowledge)

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.

Handing a file to another tool (ctx.artifacts)

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.

plugin.json (at your repo root)

{
  "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.

System requirements (system)

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.

Publishing to the marketplace

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.

API surface

  • 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 conversion defineTool uses.
  • 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.

Dev mode: run a local tool inside a remote agent

@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. loadToolsFromPaths only does a dynamic import(); 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) with ws. 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, and reconnect: false arms no retry at all.
  • ctx.state is 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.

Hooks

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.

Versioning

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.

License

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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages