Write a Plugin
Build a plugin with durable storage, an ungated read, a gated write, and settings, then test it without a deployment.
A plugin is a directory of TypeScript that the deployment compiles at boot. Its layout declares its contents: a config at src/config.ts, one tool per file under src/tools/, one skill per directory under src/skills/. This guide builds contacts: an address book agents can read without approval and update only with approval.
Prerequisites: a running deployment, and Node.js wherever you write the plugin. You don’t need a checkout of Collegium.
Plugins run with framework privilege. Review a plugin before you install it, the same way you review any deployed code.
1. Scaffold the Package
mkdir contacts && cd contacts
npm init --yes
npm install @collegium/sdk zod
mkdir -p src/toolsSet type and dependencies in package.json:
{
"name": "contacts",
"type": "module",
"version": "0.0.0",
"dependencies": {
"@collegium/sdk": "^0.0.1-beta.6",
"zod": "^4.4.3"
}
}The framework checks two things at boot and refuses to start if either fails:
@collegium/sdkandzodare both declared, and they are the only entries independencies. Puttypescript,vitest, and other tooling indevDependencies.- Each declared range matches the version the deployment carries. A range you never state is one the deployment can never check, which is why both are required.
The copies you install serve your editor and your tests. The deployment compiles your imports against its own copies, so exactly one zod runs in the process.
There is no entry file to declare and nothing to build. The framework compiles the layout itself.
2. Declare the Config
src/config.ts default-exports the config: the settings schema agents are configured by, and the storage collections the plugin owns.
import { defineConfig } from '@collegium/sdk';
import { z } from 'zod';
declare module '@collegium/sdk' {
interface Register {
config: typeof config;
}
}
const config = defineConfig({
settings: z.strictObject({
maxContacts: z.number().int().positive().default(200)
}),
storage: {
contacts: z.object({
email: z.email(),
name: z.string().min(1)
})
}
});
export default config;The declare module block registers the config’s types. It is what types settings and storage inside every tool file, with no import between them.
settingstypes thesettingseach tool receives. Agents set values undertoolSettings.contacts.storagedeclares one collection per key. The framework validates every read and write against the schema.
Each collection is a set of records. A record is the schema’s output plus id, createdAt, and updatedAt, which the store stamps, so the schema must not declare those names. The handle has six methods:
create(data)parsesdatathrough the schema, so defaults apply, and returns the record. Passidindatato choose the identifier; omit it and the store mints a cuid2. An id the collection already holds is an error, so check withfindByIdfirst when the model supplies it.findMany(query?)returns every record in insertion order, or those matchingquery.query.whereis an AND over the schema’s top-level scalar fields andid. A field condition is a value for equality,{ in: [...] }for membership, or, on a string field,{ contains: text }for a case-insensitive substring.query.limitcaps the result. An undeclared or non-scalar field is a compile error.findFirst(query?)returns the earliest record matchingquery.where, ornull. It takes the samewhereasfindManyand nolimit.findById(id)returns the record ornull.updateById(id, patch)merges the patch over the stored fields, parses the whole against the schema, and returns the record, ornullfor an unknown id.deleteById(id)returns whether a record went.
3. Write the Tools
Every src/tools/<name>.ts default-exports one tool, and the filename is the tool’s name: src/tools/find.ts becomes contacts::find. The parameters schema types args in every callback.
src/tools/find.ts is the ungated read:
import { defineTool } from '@collegium/sdk';
import { z } from 'zod';
export default defineTool({
approval: null,
description: 'Find saved contacts by name.',
execute: async (args, { storage }) => {
const matches = await storage.contacts.findMany({ where: { name: { contains: args.query } } });
if (!matches.length) {
return 'no contacts matched';
}
return matches.map(({ email, id, name }) => `- ${id}: ${name} <${email}>`).join('\n');
},
parameters: z.object({
query: z.string().min(1).describe('Name or fragment to search for')
}),
retryable: true
});src/tools/save.ts is the gated write:
import { defineTool } from '@collegium/sdk';
import { z } from 'zod';
export default defineTool({
approval: (args) => ({
body: `save contact "${args.id}": ${args.name} <${args.email}>`,
presentation: 'verbatim'
}),
description: 'Save a contact.',
execute: async (args, { err, settings, storage }) => {
if (await storage.contacts.findById(args.id)) {
err.invalidArguments(`contact ${args.id} already exists; choose another identifier`);
}
const existing = await storage.contacts.findMany();
if (existing.length >= settings.maxContacts) {
err.invalidArguments(`contact limit of ${settings.maxContacts} reached; delete one first`);
}
await storage.contacts.create({ email: args.email, id: args.id, name: args.name });
return `contact ${args.id} saved`;
},
parameters: z.object({
email: z.email().describe('Email address'),
id: z.string().min(1).describe('Short identifier to save under'),
name: z.string().min(1).describe('Full name')
}),
traceDetail: (args) => `${args.id}: ${args.name}`
});executereturns the text the model reads. Return{ text, disclosure }to disclose a durable record.errraises the two failures a tool may raise itself.err.invalidArgumentsfeeds the message back to the model and the turn continues.err.unresolvedends the turn as an unconfirmed side effect. Any other throw ends the turn as an error.approvalis required, and every tool states its gate: a function, ornull. Omitting the key refuses the plugin at boot, because an absent field cannot be told from a forgotten one.- A tool whose
approvalis a function blocks for a human decision on every call. The function returns the payload the human sees; include everything the action will do. - A tool declaring
approval: nullruns without a gate. Mark itretryableonly if the call is safe to run again with the same arguments: a timed-out call is then reported as a failure, and a restart may run its turn again. Never mark a mutation retryable unless repeating it changes nothing. traceDetailis the one-line summary shown in the status post.
Render the Stored Record
approval receives a second argument beside args: the tool’s settings, and each collection’s three reads, findById, findFirst, and findMany. It gets no writes, no err, no turn, and no workUnits. It may be async.
When a call acts on a stored record, read the record and show it. The approver then checks what the store holds, not the model’s description of it. src/tools/delete.ts deletes a contact:
import { defineTool } from '@collegium/sdk';
import { z } from 'zod';
export default defineTool({
approval: async (args, { storage }) => {
const contact = await storage.contacts.findById(args.id);
return {
body: contact
? `delete contact "${args.id}": ${contact.name} <${contact.email}>`
: `delete contact "${args.id}", which is not saved`,
presentation: 'verbatim'
};
},
description: 'Delete a saved contact.',
execute: async (args, { err, storage }) => {
if (!(await storage.contacts.deleteById(args.id))) {
err.invalidArguments(`no contact is saved as ${args.id}`);
}
return `contact ${args.id} deleted`;
},
parameters: z.object({
id: z.string().min(1).describe('Identifier the contact was saved under')
})
});If the model names the wrong identifier, the prompt shows the wrong contact, and the approver denies the call.
Build the payload from the arguments, the settings, and your own records only. Don’t call fetch or read files in a render. The approver must see what the call acts on, not what a network returned. A render that throws ends the turn as an error before any prompt appears.
Read the Turn and Its Work Unit
execute also receives turn, the facts of the turn making the call:
agentUsernamenames the acting agent, andchannelIdthe channel it acts in.isGranted(ref)says whether the acting agent is granted a tool, named by its fullnamespace::toolref. A result or refusal that tells the agent which tool to call next names only one that passes this check, since a deployment may grant your tools one by one.triggeringPostIdnames the post that started the turn, or isnullwhen no post did.turnIdidentifies the turn.workUnitnames the work unit the turn serves, or isnull.
A work unit is the framework’s record of work one agent handed another with tasks::assign. Each carries a short reference such as q3m8v1zd. turn.workUnit holds the unit’s reference and creatorUsername when the unit’s assignment post started the turn and the unit was still assigned then. It stays set for the whole turn. Every other turn gets null, so a record you stamp with it is never attributed to the wrong unit.
workUnits.find(reference) reads a unit’s reference, state, creatorUsername, assigneeUsername, outcome, criteria, context, createdAt, and updatedAt. It reaches the units the acting agent created or was assigned in this channel, and returns null for any other reference.
Three more fields appear only where they apply. follows names the unit this one carries on, and continuedBy names the unit that carries this one on. closure tells you how a closed unit closed:
fromis the state it closed from:assigned,review, orblocked.viais what closed it:closefor its creator’stasks::close,continuationfor atasks::assignthat follows it, orcancellationfor a person’s cancel.
A unit closed before release 0.0.1-beta.30 has no closure.
A plugin can’t create, change, or close a unit. Store the reference on your own records and read the unit’s state when you need it, rather than keeping a copy that can drift:
const unit = await workUnits.find(args.unit);
if (unit?.state === 'assigned') {
err.invalidArguments(`work unit ${unit.reference} is still with @${unit.assigneeUsername}; wait for their report`);
}Files and Imports
The loader reads direct children of src/tools/ only, and refuses a file it cannot load as a tool rather than skipping it. Every direct child must end in .ts and carry no further extension, so save.test.ts and types.d.ts are refused there — a subdirectory such as src/tools/__tests__/ is yours, and the loader ignores it. Keep helpers elsewhere under src/ and import them by relative path.
Import @collegium/sdk, zod, node: builtins, and your own files by relative path. The compiler refuses any other bare specifier at boot. Import from zod itself, not a subpath.
fetch, crypto, Intl, and structuredClone are available as globals.
To ship skills, add a directory per skill under src/skills/. Each src/skills/<name>/ becomes the grant contacts::<name>, and holds its procedure at SKILL.md:
src/skills/saving-contacts/
SKILL.md
references/name-formats.mdThe directory name must be lowercase and dashed (saving-contacts), just as a tool’s filename must be lowercase snake_case. SKILL.md and every references/<name>.md carry title and description frontmatter above a markdown body. SKILL.md may also carry tools, naming what the procedure calls — tools: [mail, conversations::search], a namespace or a single namespace::tool ref. An agent granted the skill without one of them refuses the boot, rather than the model discovering it three steps in. Any other frontmatter key refuses the boot too, so a misspelled one is never silently dropped.
An agent pulls a skill into context with skills::load, and one of its references by naming that reference in the same call. It does not have to be told the references exist: the framework appends the index to the body it returns. Keep SKILL.md to the procedure and put the detail an agent only sometimes needs in a reference — the turn that does not need it never pays for it.
A loose file under src/skills/, a skill directory with no SKILL.md, or a references/ entry that is not a single dashed .md document refuses the boot rather than being skipped. See the in-repo bookmark plugin for a working example.
4. Test the Tools
@collegium/sdk/testing builds the context a tool receives, so you can call execute without a deployment. Storage is in memory and behaves as the deployment’s store does: validated on write, parsed on read. Settings pass through your schema, so defaults apply.
npm install --save-dev vitestsrc/tools/__tests__/save.test.ts:
import { createTestContext, PluginToolFailureError } from '@collegium/sdk/testing';
import { describe, expect, it } from 'vitest';
import config from '../../config.ts';
import deleteContact from '../delete.ts';
import find from '../find.ts';
import save from '../save.ts';
describe('contacts', () => {
it('saves a contact and finds it', async () => {
const context = createTestContext(config);
await save.execute({ email: 'elena@northshoresummit.co', id: 'elena', name: 'Elena Baptiste' }, context);
expect(await find.execute({ query: 'elena' }, context)).toBe('- elena: Elena Baptiste <elena@northshoresummit.co>');
});
it('refuses a save past the limit', async () => {
const context = createTestContext(config, { settings: { maxContacts: 1 } });
await save.execute({ email: 'ana@example.com', id: 'ana', name: 'Ana' }, context);
const second = save.execute({ email: 'ben@example.com', id: 'ben', name: 'Ben' }, context);
await expect(second).rejects.toThrow(PluginToolFailureError);
});
it('shows the approver the stored contact before a delete', async () => {
const context = createTestContext(config);
await save.execute({ email: 'ana@example.com', id: 'ana', name: 'Ana' }, context);
const payload = await deleteContact.approval?.({ id: 'ana' }, context);
expect(payload?.body).toBe('delete contact "ana": Ana <ana@example.com>');
});
});err.invalidArguments and err.unresolved throw PluginToolFailureError here, as they do in a deployment before the framework maps them. Pass turn in the options to change the agent, channel, post, or work unit the tool sees. Pass workUnits to give workUnits.find the units it can reach; without them, it finds none. The same context serves approval, which reads the same in-memory storage.
5. Install the Plugin
Clone the plugin into the directory PLUGINS_ROOT in .env points at. In a clone of Collegium, that is plugins/:
git clone https://github.com/you/contacts plugins/contactsThe subdirectory name is the plugin’s name and namespace. The framework reads it from nowhere else.
6. Grant and Configure
In config.json, declare the plugin:
"plugins": ["contacts"]Then grant it to Clara and set her settings for it:
"tools": ["web", "workspace", "memory", "contacts"],
"toolSettings": {
"contacts": {
"maxContacts": 500
}
}"contacts" grants every tool in the plugin, including ones a later version adds. Grant "contacts::find" instead to hand out one tool. Settings merge over agentDefaults.toolSettings.contacts. The framework refuses settings for an ungranted toolset and settings the schema rejects.
7. Try It
Restart the deployment, then:
@clara Save Elena Baptiste, elena@northshoresummit.co, as our Northshore Summit contact.
The approval prompt shows what will be written. Approve it, then:
@clara Who do we know at Northshore Summit?
The find runs without a gate, and the answer comes back from the plugin’s records.