Skip to main content

Example cookbook

25 supplied packages demonstrate the public contract. Commentary is written for this guide; code excerpts are copied verbatim from the named files. Some original strings are French. Regenerate excerpts with node scripts/gen-examples.mjs.

Obtain the example tree with your developer distribution. There is no public repository link. Use a licensed test instance and follow Testing before installing third-party code.

Content in a workspace tab or split​

Source: examples/plugin-island. Manifest ID: example-island. Minimum API: 8.

Client API 8. Registers example-island:board in island.content. Ask an agent to open it with id demo through islands_propose; its button opens or drags another board. The host owns tabs and resize controls. No host entry or permissions.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-island installs this package on the target instance.

From examples/plugin-island/lib/client.js, line 30 (excerpt):

export function apply(ctx) {
function Board() {
const { params, setTitle, close } = useIslandContent()
const [error, setError] = React.useState(false)
const title = t('board', { id: String(params.id) })
React.useEffect(() => { setTitle(title) }, [setTitle, title])
const nextId = params.id === 'demo' ? 'archive' : 'demo'
const content = { type: `${ctx.id}:board`, params: { id: nextId } }
return React.createElement('section', { style: { padding: 16, height: '100%', overflow: 'auto' } },
React.createElement('h2', { style: { fontWeight: 600 } }, title),
React.createElement('p', { style: { marginTop: 8 } }, t('description')),
React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 16 } },
React.createElement(Button, {
type: 'button',
draggable: true,
onDragStart: (event) => startIslandDrag(event, content, { label: t('board', { id: nextId }) }),
onClick: () => setError(!openInIsland(content, { edge: 'right' })),
}, t('open', { id: nextId })),
React.createElement(Button, { type: 'button', variant: 'ghost', onClick: close }, t('close'))),
error ? React.createElement('p', { role: 'status', style: { marginTop: 8 } }, t('refused')) : null)
}

return ctx.slots.register({
slot: 'island.content',
key: 'board',
island: {
title: t('board', { id: 'demo' }),
parseParams,
params: { id: { type: 'string', required: true, maxLength: 40, description: 'Board slug (lowercase letters, digits, hyphens).' } },
singletonBy: 'id',
minWidth: 280,
minHeight: 160,
},
}, Board)
}

A movable card​

Source: examples/plugin-widget. Manifest ID: example-widget. Minimum API: 1.

Use this for a browser-only card. React comes from the platform; the returned slot disposer releases the card when the plugin unloads.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-widget installs this package on the target instance.

From examples/plugin-widget/lib/client.js, line 11 (excerpt):

export function apply(ctx) {
function ExampleWidget() {
return React.createElement(
'section',
{ style: { margin: 12, padding: 16, border: '1px solid currentColor', borderRadius: 12 } },
React.createElement('strong', null, 'Example widget'),
React.createElement('p', null, `plugin ${ctx.id}`),
)
}

// `icon`: the card's picto in the widget window (SVG markup or the URL of
// an SVG), painted in the theme's text colour — only its shape counts.
return ctx.slots.register(
{ slot: 'cockpit.widget', widget: { title: 'Example widget', icon: ICON } },
ExampleWidget,
)
}

A route and two UI insertions​

Source: examples/plugin-hello. Manifest ID: plugin-hello. Minimum API: 1.

The host serves a greeting from a setting. The browser calls its own plugin route with ctx.api and registers navigation plus a widget. The original French strings are preserved in these verbatim source excerpts.

Permissions: settings. Read the related guide.

timon plugin add ./examples/plugin-hello installs this package on the target instance.

From examples/plugin-hello/lib/index.js, line 1 (excerpt):

export const name = 'plugin-hello'
export const inject = ['settings']

/** @param {import('@timon-ai/sdk').PluginContext} ctx */
export function apply(ctx) {
const router = ctx.router()
router.get('/hello', (_request, response) => {
response.json({
plugin: ctx.id,
greeting: ctx.settings.get('greeting') ?? 'Bonjour depuis un plugin',
})
})
}

From examples/plugin-hello/lib/client.js, line 16 (excerpt):

export function apply(ctx) {
function HelloWidget() {
const [message, setMessage] = React.useState('Chargement…')
React.useEffect(() => {
let active = true
ctx.api('hello')
.then((response) => response.json())
.then((body) => { if (active) setMessage(String(body.greeting)) })
.catch(() => { if (active) setMessage('Plugin indisponible') })
return () => { active = false }
}, [])
return React.createElement(
'section',
{ style: { margin: 12, padding: 16, border: '1px solid currentColor', borderRadius: 12 } },
React.createElement('strong', null, 'Plugin Hello'),
React.createElement('p', null, message),
)
}

const disposeNav = ctx.slots.register({ slot: 'nav.item' }, HelloNav)
const disposeWidget = ctx.slots.register({ slot: 'cockpit.widget' }, HelloWidget)
return () => {
disposeWidget()
disposeNav()
}
}

A page with a child slot​

Source: examples/plugin-page. Manifest ID: example-page. Minimum API: 1.

Open /example after installation. The route key and manifest route agree; the child slot is rendered by the page, not merely declared.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-page installs this package on the target instance.

From examples/plugin-page/lib/client.js, line 10 (excerpt):

export function apply(ctx) {
function ExamplePage() {
return React.createElement(
'main',
{ style: { padding: 24 } },
React.createElement('h1', null, 'Example page'),
React.createElement(Slot, { name: 'example.widget' }),
)
}

return ctx.slots.register(
{ slot: 'page', key: '/example', children: { 'example.widget': 'list' } },
ExamplePage,
)
}

A card and an in-app overlay​

Source: examples/plugin-overlay. Manifest ID: example-overlay. Minimum API: 1.

Install once to obtain both surfaces. Each registration gets a disposer, and unloading releases both. The overlay positions itself inside the browser window.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-overlay installs this package on the target instance.

From examples/plugin-overlay/lib/client.js, line 7 (excerpt):

function Body() {
return React.createElement('strong', null, 'Example overlay')
}

/** @param {import('@timon-ai/sdk').ClientContext} ctx */
export function apply(ctx) {
const disposeWidget = ctx.slots.register(
{ slot: 'cockpit.widget' },
function OverlayCard() {
return React.createElement(
'section',
{ style: { margin: 12, padding: 16, border: '1px solid currentColor', borderRadius: 12 } },
React.createElement(Body),
)
},
)

const disposeOverlay = ctx.slots.register(
{ slot: 'overlay' },
function OverlayMascot() {
return React.createElement(
'div',
{
style: {
position: 'fixed',
right: 16,
bottom: 16,
zIndex: 40,
padding: 12,
borderRadius: 16,
border: '1px solid currentColor',
},
},
React.createElement(Body),
)
},
)

return () => {
disposeOverlay()
disposeWidget()
}
}

A token palette​

Source: examples/plugin-theme. Manifest ID: example-theme. Minimum API: 1.

Select this installed theme in Appearance. TOKENS contains the dark and light palettes; this excerpt shows how a React-owned style is mounted and removed. The old template comment about missing tokens predates the runtime theme floor; see the theme guide for partial palettes.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-theme installs this package on the target instance.

From examples/plugin-theme/lib/client.js, line 167 (excerpt):

export function apply(ctx) {
// No `fallback: true`: the product theme owns that role. Claiming the slot
// is what makes the palette visible, and disposing restores the product's.
return ctx.slots.register({ slot: 'theme' }, function ExampleTheme() {
return React.createElement('style', {
id: 'example-theme-tokens',
dangerouslySetInnerHTML: { __html: TOKENS },
})
})
}

A minimal root layout​

Source: examples/plugin-blank-layout. Manifest ID: blank-layout. Minimum API: 1.

This replaces the chrome with the reserved Next page tree. It declares only page, so other layout-owned regions are absent. Test it on a separate instance and retain a way to restore the product layout.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-blank-layout installs this package on the target instance.

From examples/plugin-blank-layout/client.js, line 4 (excerpt):

export function apply(ctx) {
return ctx.slots.register(
// `page` is keyed by route: a layout paints the Next tree's reserved
// key, never the whole slot — every plugin page would stack up.
{ slot: 'root', children: { page: 'keyed' } },
function BlankLayout() { return React.createElement(SlotKeyed, { name: 'page', entryKey: 'next-router' }); },
);
}

Native and custom settings​

Source: examples/plugin-settings. Manifest ID: example-settings. Minimum API: 1.

The first registration adds a row in Missions. The second supplies the plugin category and opens a child slot for other extensions. Both registrations are disposed together.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-settings installs this package on the target instance.

From examples/plugin-settings/lib/client.js, line 36 (excerpt):

const disposeGraft = ctx.slots.register({ slot: 'settings.missions' }, MissionsGraft)
const disposeSection = ctx.slots.register(
{ slot: 'settings.section', children: { 'settings.acme': 'list' } },
OwnCategory,
)
return () => {
disposeSection()
disposeGraft()
}
}

Install a page and widget together​

Source: examples/plugin-bundle. Manifest ID: example-bundle. Minimum API: 1.

The manifest contains two child IDs, while npm dependencies resolve their packages. The sibling file paths are for the local example tree, not a published registry package. Its host apply intentionally does nothing.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-bundle installs this package on the target instance.

From examples/plugin-bundle/package.json, line 1:

{
"name": "@timon-ai/plugin-bundle",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "lib/index.js",
"dependencies": {
"@timon-ai/plugin-page": "file:../plugin-page",
"@timon-ai/plugin-widget": "file:../plugin-widget"
},
"files": ["lib", "README.md", "SIGNATURE"],
"timon": {
"id": "example-bundle",
"apiVersion": 1,
"kind": "bundle",
"label": "Tableau exemple",
"description": "Installe la page d'exemple et son widget. Chaque enfant reste un plugin à part.",
"contains": ["example-page", "example-widget"],
"permissions": [],
"settings": [],
"license": { "required": false }
}
}

From examples/plugin-bundle/lib/index.js, line 2 (excerpt):

export const name = 'example-bundle'
export const inject = []

export function apply() {}

A provider pack without code​

Source: examples/plugin-provider-pack. Manifest ID: provider-pack-example. Minimum API: 2.

Two declarative profiles reuse installed CLI dialects. Replace the example endpoints and model IDs, then enter the key in the plugin settings. The manifest contains no main and no JavaScript is loaded. Signature and licence rules still apply.

Permissions: backends:register. Read the related guide.

timon plugin add ./examples/plugin-provider-pack installs this package on the target instance.

From examples/plugin-provider-pack/package.json, line 1:

{
"name": "@timon-ai/plugin-provider-pack",
"version": "0.1.0",
"private": true,
"type": "module",
"files": ["README.md", "SIGNATURE"],
"timon": {
"id": "provider-pack-example",
"apiVersion": 2,
"kind": "provider",
"label": "Example provider pack",
"description": "Two declarative providers using existing CLI dialects.",
"author": "Timon",
"permissions": [
"backends:register"
],
"settings": [
{
"key": "apiKey",
"type": "secret",
"label": "Provider API key"
}
],
"backends": [
{
"id": "pack-anthropic",
"dialect": "claude",
"label": "Example Anthropic endpoint",
"env": {
"ANTHROPIC_BASE_URL": "https://anthropic.example.test",
"ANTHROPIC_AUTH_TOKEN": {
"setting": "apiKey"
}
},
"defaultModel": "example-model",
"models": [
{
"slug": "example-model",
"label": "Example model"
}
]
},
{
"id": "pack-responses",
"dialect": "codex",
"label": "Example Responses endpoint",
"env": {
"EXAMPLE_API_KEY": {
"setting": "apiKey"
}
},
"providerConfig": {
"id": "example",
"name": "Example Responses",
"baseUrl": "https://responses.example.test/v1",
"envKey": "EXAMPLE_API_KEY",
"wireApi": "responses"
},
"defaultModel": "example-model",
"models": [
{
"slug": "example-model",
"label": "Example model"
}
]
}
],
"license": {
"required": false
}
}
}

A ready-to-install OpenRouter pack​

Source: examples/plugin-provider-openrouter. Manifest ID: provider-openrouter-example. Minimum API: 2.

One declarative profile for OpenRouter, a provider with a fixed OpenAI Responses endpoint, run by the installed codex dialect. Enter the OpenRouter key in the plugin settings: the backend turns active without a restart. The model list is not in the manifest: modelsEndpoint points at OpenRouter's public listing, read in the background. The backend ID is openrouter-pack, so it does not collide with an openrouter backend written by hand from Add a provider. No main, no JavaScript; signature and licence rules still apply.

Permissions: backends:register. Read the related guide.

timon plugin add ./examples/plugin-provider-openrouter installs this package on the target instance.

From examples/plugin-provider-openrouter/package.json, line 1:

{
"name": "@timon-ai/plugin-provider-openrouter",
"version": "0.1.0",
"private": true,
"type": "module",
"files": ["README.md", "SIGNATURE"],
"timon": {
"id": "provider-openrouter-example",
"apiVersion": 2,
"kind": "provider",
"label": "OpenRouter",
"description": "OpenRouter through its OpenAI Responses endpoint, with its live model list.",
"author": "Timon",
"permissions": [
"backends:register"
],
"settings": [
{
"key": "apiKey",
"type": "secret",
"label": "OpenRouter API key"
}
],
"backends": [
{
"id": "openrouter-pack",
"dialect": "codex",
"label": "OpenRouter",
"vendor": "OpenRouter",
"env": {
"BACKEND_OPENROUTER_API_KEY": {
"setting": "apiKey"
}
},
"providerConfig": {
"id": "openrouter-pack",
"name": "OpenRouter",
"baseUrl": "https://openrouter.ai/api/v1",
"envKey": "BACKEND_OPENROUTER_API_KEY",
"wireApi": "responses"
},
"defaultModel": null,
"models": [],
"modelsEndpoint": {
"url": "https://openrouter.ai/api/v1/models",
"format": "openai"
}
}
],
"license": {
"required": false
}
}
}

Replace the AI tool cards​

Source: examples/plugin-provider-cards. Manifest ID: example-provider-cards. Minimum API: 14.

API 14. One registration in the single providers.cli slot replaces the AI tool cards of the first-run Your AIs step and of Settings › AI models › Providers with one compact list. The component reads everything from useProviderCliCards(): the tools, their install jobs and four actions. Timon runs the installer and the sign-in command; the plugin only names a tool. Offer Install only for a tool marked installable and link its installDocs otherwise.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-provider-cards installs this package on the target instance.

From examples/plugin-provider-cards/lib/client.js, line 63 (excerpt):

export function apply(ctx) {
ctx.slots.register({ slot: 'providers.cli' }, CompactProviderCards)
}

Replace the task form​

Source: examples/plugin-task-form. Manifest ID: example-task-form. Minimum API: 14.

API 14. One registration in the single task.form slot replaces the task form opened from the Kanban board, the Agents page and the agents grid with a one-screen quick form. The component reads everything from useTaskForm(): the edited task (or none), the agents, submit, the attachment actions, finish and close. Timon keeps the window around it (focus, Escape) and makes the calls. A replacement puts titleId on its heading and sends back the fields it does not show as the task holds them, so saving never wipes them. Remove the plugin and Timon's own form comes back.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-task-form installs this package on the target instance.

From examples/plugin-task-form/lib/client.js, line 36 (excerpt):

function QuickTaskForm() {
const host = useTaskForm()
const task = host?.task ?? null
const [title, setTitle] = React.useState(task?.title ?? '')
const [description, setDescription] = React.useState(task?.description ?? '')
const [agent, setAgent] = React.useState(task?.agent_name ?? host?.defaultAgent ?? host?.agents[0]?.name ?? '')
const [priority, setPriority] = React.useState(task?.priority ?? 3)
const [error, setError] = React.useState(null)
if (!host) return null

const save = async (event) => {
event.preventDefault()
if (!title.trim() || !agent) return
const result = await host.submit({
title: title.trim(),
description,
agent_name: agent,
priority,
type: task?.type ?? 'chore',
deps: list(task?.deps),
scheduled_at: task?.scheduled_at ?? null,
backend_override: task?.backend_override ?? null,
model_override: task?.model_override ?? null,
fallback_backend_override: task?.fallback_backend_override ?? null,
fallback_model_override: task?.fallback_model_override ?? null,
permission_mode_override: task?.permission_mode_override ?? null,
extra_skills: list(task?.extra_skills),
extra_mcp_servers: list(task?.extra_mcp_servers),
extra_allowed_tools: list(task?.extra_allowed_tools),
todo_id: task?.todo_id ?? null,
})
if (result.ok) host.finish()
else setError(t('failed', { error: result.error }))
}

return React.createElement('form', { onSubmit: save, className: 'flex flex-1 flex-col gap-4 p-6' },
React.createElement('h2', { id: host.titleId, className: 'text-base font-semibold' }, host.mode === 'edit' ? t('edit') : t('create')),
React.createElement(Input, { 'aria-label': t('title'), placeholder: t('title'), value: title, autoFocus: true, onChange: (e) => setTitle(e.target.value) }),
React.createElement('textarea', {
'aria-label': t('description'), placeholder: t('description'), value: description, rows: 8,
onChange: (e) => setDescription(e.target.value),
className: 'rounded-md bg-muted/50 px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring',
}),
React.createElement('div', { className: 'flex flex-wrap items-center gap-3' },
React.createElement('select', {
'aria-label': t('agent'), value: agent, onChange: (e) => setAgent(e.target.value),
className: 'h-9 rounded-md bg-muted/50 px-2 text-sm',
}, host.agents.map((a) => React.createElement('option', { key: a.name, value: a.name }, a.name))),
React.createElement('select', {
'aria-label': t('priority'), value: String(priority), onChange: (e) => setPriority(Number(e.target.value)),
className: 'h-9 rounded-md bg-muted/50 px-2 text-sm tabular-nums',
}, [5, 4, 3, 2, 1].map((p) => React.createElement('option', { key: p, value: String(p) }, `P${p}`)))),
error && React.createElement('p', { role: 'alert', className: 'text-sm text-destructive' }, error),
React.createElement('div', { className: 'mt-auto flex justify-end gap-2' },
React.createElement(Button, { type: 'button', variant: 'ghost', size: 'sm', onClick: host.close }, t('cancel')),
React.createElement(Button, { type: 'submit', size: 'sm', 'data-quick-save': '' }, t('save'))))
}

/** @param {import('@timon-ai/sdk').ClientContext} ctx */
export function apply(ctx) {
ctx.slots.register({ slot: 'task.form' }, QuickTaskForm)
}

Profiles and dialects​

Source: examples/plugin-provider. Manifest ID: provider-example. Minimum API: 12.

This demonstration registers its profile unconditionally: the env function throws while the secret setting is empty, which keeps the backend out of the menus and out of dispatch until a key is saved. It also includes an echo dialect describing a fictitious JSON-speaking executable, not the operating system echo command. It is a protocol template, not a working AI backend. Provider endpoints and model IDs below are literal example data, not current catalogue recommendations.

Permissions: backends:register, settings. Read the related guide.

timon plugin add ./examples/plugin-provider installs this package on the target instance.

From examples/plugin-provider/lib/index.js, line 17 (excerpt):

ctx.backends.registerProfile({
id: 'ollama-cloud-example',
dialect: 'codex',
label: 'Ollama Cloud (example)',
vendor: 'Ollama Cloud',
brand: 'ollama',
custom: true,
// Resolve on every mission launch, including short-lived token refresh.
// The daemon never relays the error message; keep credentials out of it.
env: () => {
const apiKey = String(ctx.settings.get('apiKey') ?? '')
if (!apiKey) throw new Error('apiKey setting is empty')
return { OLLAMA_API_KEY: apiKey }
},
providerConfig: {
id: 'ollama-cloud-example',
name: 'Ollama Cloud (example)',
baseUrl: 'https://ollama.com/v1',
envKey: 'OLLAMA_API_KEY',
wireApi: 'responses',
},
defaultModel: 'gpt-oss:120b',
models: [
{ slug: 'gpt-oss:120b', label: 'gpt-oss:120b', contextWindow: 128_000 },
{ slug: 'gpt-oss:20b', label: 'gpt-oss:20b', contextWindow: 32_000 },
],
})

From examples/plugin-provider/lib/echo-dialect.js, line 41 (excerpt):

async buildSpawn(opts, profile) {
const args = ['--model', opts.cfg.model ?? 'echo-1', '--system', opts.systemPrompt]
if (opts.resumeSession) args.push('--resume', opts.resumeSession)
args.push(opts.message)
return {
cmd: profile.cmd,
args,
// Use the resolved profile environment, never process.env.
env: { ...profile.env },
cwd: opts.cfg.cwd,
initialStdin: null,
}
},

A third-party ACP provider (Cursor)​

Source: examples/plugin-provider-cursor. Manifest ID: provider-cursor. Minimum API: 12.

API 12. Cursor's agent over the Agent Client Protocol (cursor-agent acp), written as an outside developer would: it imports only @timon-ai/sdk, and Timon treats it as a stranger — its program starts only after the superadmin authorizes the path Timon resolved, and it never receives the mission token, the vault or the daemon's environment. The agent is one row of data handed to the daemon's ACP runtime. Cursor's own multiple-choice questions and plans are answered with Timon's cards (extensionRequests), its program is checked by its version banner when the superadmin authorizes it and before it runs (binary.identity, in the code and the manifest alike), which makes its generic alias agent safe to declare, and what was never seen work is declared (unverified): the model picker shows it as an Unverified chip. Read the template's README before installing: it was written from Cursor's documentation and has not been run against a real Cursor, and Timon's own tools may not reach a Cursor mission.

Permissions: backends:register. Read the related guide.

timon plugin add ./examples/plugin-provider-cursor installs this package on the target instance.

From examples/plugin-provider-cursor/lib/cursor.js, line 20 (excerpt):

export const CURSOR = Object.freeze({
id: 'cursor',
// `cursor-agent acp`: documented as "advanced", absent from `--help`. The whole CLI speaks ACP behind that subcommand.
args: ['acp'],
// Cursor sends `session/request_permission` ("If your client does not answer permission requests, tool execution can block"):
// Timon arbitrates every tool use, through the card and the classifier.
permissionsByAgent: false,
// Which models an account offers is Cursor's to say, not this file's. The probe leaves an empty session in Cursor's history.
modelProbe: 'session-new',
// The one method Cursor announces. Tried ONCE, only if `session/new` answers `auth_required`: it reuses the sign-in
// `cursor-agent login` stored and opens nothing by itself.
authenticateWith: 'cursor_login',
signIn: { byMethod: { cursor_login: SIGN_IN }, fallback: SIGN_IN },
binary: {
// The canonical name, then `agent`, which Cursor's documentation also uses. `agent` alone proves nothing (grok installs
// one in the same folder): the identity below, mirrored in package.json, refuses a namesake on the authorization click
// and before every launch. `index.js` puts first the name this machine resolves.
names: ['cursor-agent', 'agent'],
install: {
any: { kind: /** @type {'script'} */ ('script'), posix: 'curl https://cursor.com/install -fsS | bash', windows: "irm 'https://cursor.com/install?win32=true' | iex" },
},
// Checked on the authorization click (the copy in package.json, which must stay identical), before a mission starts the
// program and before the model probe: a program that does not print a dated version (Cursor's builds are named by
// date, `2026.10.16-…`) is refused, saying what it printed.
identity: { args: ['--version'], pattern: '\\b20\\d{2}\\.\\d{1,2}\\.\\d{1,2}\\b', expected: "Cursor's agent CLI, a dated version such as 2026.10.16" },
},
// Cursor's own blocking requests: its multiple-choice questions and its plans, put to the person as Timon cards.
extensionRequests: CURSOR_EXTENSIONS,
// The folders `cursor-agent` keeps its sign-in and configuration in. Spared from the mission's mask for a Cursor mission only,
// masked from every other backend. `.config/cursor` is a guess for Linux: confirm on a real install (A-TESTER).
declares: {
credentialDirs: ['.cursor', '.config/cursor'],
// Shown with the backend (an Unverified chip in the model picker): what this plugin has never seen work.
unverified: /** @type {ProviderUnverified[]} */ ([
{ subject: 'session', note: 'Written from Cursor\'s ACP documentation; its tests replay reconstructed sessions, not recorded ones.' },
{ subject: 'mcp', note: 'Timon\'s tools are declared through the session; Cursor documents only .cursor/mcp.json, so they may be missing.' },
{ subject: 'extension-requests', note: 'Questions and plans follow the shapes Cursor documents; never answered by a real cursor-agent.' },
{ subject: 'credential-dirs', note: '~/.config/cursor is a guess for Linux; native Windows keeps its configuration elsewhere.' },
{ subject: 'binary-identity', note: 'The dated version banner is inferred from Cursor\'s release names, not read from a real install.' },
]),
},
});

From examples/plugin-provider-cursor/lib/extensions.js, line 29 (excerpt):

export async function askQuestion(params, cards) {
const p = record(params)
const raw = Array.isArray(p?.questions) ? p.questions : []
/** @type {AcpCardQuestion[]} */
const questions = []
for (const item of raw) {
const q = record(item)
const id = text(q?.id)
const prompt = text(q?.prompt)
if (!q || !id || !prompt) return skipped('the question could not be read')
const options = (Array.isArray(q.options) ? q.options : []).map(record)
if (options.some((o) => !o || !text(o.id) || !text(o.label))) return skipped('an option could not be read')
questions.push({
id,
question: prompt,
...(questions.length === 0 && text(p?.title) ? { header: /** @type {string} */ (p?.title).slice(0, 40) } : {}),
options: options.map((o) => ({ id: /** @type {string} */ (o?.id), label: /** @type {string} */ (o?.label) })),
...(q.allowMultiple === true ? { multiSelect: true } : {}),
})
}
if (questions.length === 0) return skipped('no question was sent')

const got = await cards.ask(questions, { id: text(p?.toolCallId) })
if (got.outcome === 'cancelled') return { outcome: { outcome: 'cancelled' } }
if (got.outcome === 'unanswered') return skipped(got.reason)
const answers = questions.map((q) => ({ questionId: q.id, selectedOptionIds: [...(got.value[q.id]?.selected ?? [])] }))
const typed = questions.filter((q) => got.value[q.id]?.text)
if (typed.length === 0) return { outcome: { outcome: 'answered', answers } }
const words = questions.map((q) => {
const answer = got.value[q.id]
const picked = (q.options ?? []).filter((o) => answer?.selected.includes(o.id)).map((o) => o.label)
return `${q.question} — ${[picked.length ? `picked ${picked.join(', ')}` : 'nothing picked', answer?.text ? `wrote: ${answer.text}` : ''].filter(Boolean).join('; ')}`
})
return skipped(`The person answered in their own words. ${words.join(' | ')}`)
}

/**
* `cursor/create_plan`: `{ toolCallId, name?, overview?, plan, todos: [{ id, content, status }], phases? }`
* → `{ outcome: { outcome: 'accepted' } | { outcome: 'rejected', reason? } | { outcome: 'cancelled' } }`.
* Nobody to ask (an unattended mission) is a rejection whose reason tells the agent what to do instead.
* @type {AcpExtensionHandler}
*/

From examples/plugin-provider-cursor/lib/index.js, line 27 (excerpt):

export async function apply(ctx) {
const program = await installedName(ctx)
// The daemon launches, and the operator authorized, the first name: the one that resolved.
const names = [program, ...CURSOR.binary.names.filter((name) => name !== program)]
// Refused here, naming the program, until the operator authorizes it: nothing below runs without that.
ctx.provider.registerProvider(ctx.provider.runtime.acp.defineProvider(ctx.provider, { ...CURSOR, binary: { ...CURSOR.binary, names } }))

// The profile on that provider: what the agent editor and the LLM selector list. Models come from Cursor itself
// (`modelProbe`), so none is written here. No `fallbackRank`: a provider of unknown calibre is never chosen automatically.
ctx.backends.registerProfile({
id: 'cursor',
dialect: 'cursor',
label: 'Cursor',
vendor: 'Cursor',
brand: 'cursor',
custom: true,
...(program === CURSOR.binary.names[0] ? {} : { cmd: program }),
requiredBinary: program,
models: [],
defaultModel: null,
})
}

A remote backend with no process​

Source: examples/plugin-remote-backend. Manifest ID: remote-backend-example. Minimum API: 12.

This template registers a remote dialect: the agent is reached through a session driver, and the daemon spawns nothing, so no executable policy entry is needed. Its agent lives in memory and answers with an echo of the message, with no network and no server. It is a protocol template, not a working AI backend: a real plugin replaces each marked step with a call to its agent's API through ctx.fetch.

Permissions: backends:register, settings. Read the related guide.

timon plugin add ./examples/plugin-remote-backend installs this package on the target instance.

From examples/plugin-remote-backend/lib/memory-dialect.js, line 40 (excerpt):

export function createMemoryDialect(runtime) {
const { events } = runtime

const initEvent = (sessionId) => events.systemInitEvent({ sessionId })

const assistantEvent = (text) => events.assistantTextEvent(text, randomUUID())

/** A turn that ended. The factory knows `success` and `error`; a cancellation is
* a success that says it stopped early. */
function resultEvent(stopReason, text) {
const base = events.resultSuccessEvent()
return {
type: 'result',
payload: {
...(/** @type {Record<string, unknown>} */ (base.payload)),
...(stopReason === 'cancelled' ? { subtype: 'cancelled' } : {}),
...(text ? { result: text } : {}),
stop_reason: stopReason,
},
}
}

/** Server side: log an event and push it to the connected driver, if any. */
function publish(session, event) {
const entry = { seq: session.log.length + 1, event }
session.log.push(entry)
session.listener?.(entry)
}

/** Server side: the turn runs whether or not a driver is connected. */
function runTurn(session, text, delay) {
session.turn = setTimeout(() => {
session.turn = null
const reply = `Echo: ${text}`
publish(session, assistantEvent(reply))
publish(session, resultEvent('end_turn', reply))
}, delay)
}

function stopTurn(session) {
if (!session.turn) return false
clearTimeout(session.turn)
session.turn = null
publish(session, resultEvent('cancelled'))
return true
}

/**
* The driver of one session. `env` is the profile's environment, resolved for
* this launch: a real driver reads its address and key from it. `resume` is
* there only when the daemon restarted under a live mission: the state this
* driver saved with host.saveResume, and whether a turn was running.
*
* The fictitious agent replies `Echo: <message>` after a short delay. A few
* variables make it misbehave on purpose, the way a real server can:
* MEMORY_AGENT_DELAY_MS time before each reply (default 10)
* MEMORY_AGENT_FAIL 'start' refuses the handshake, 'prompt' drops the turn
* MEMORY_AGENT_END_AFTER after this many turns the agent closes the session…
* MEMORY_AGENT_END …this way: 'completed' (default), 'failed' or 'cancelled'
*
* @param {import('@timon-ai/sdk/provider').RemoteSessionOpenArgs} args
*/
function createDriver({ spawn, host, env, resume }) {
const delay = Number(env.MEMORY_AGENT_DELAY_MS ?? 10)
const endAfter = Number(env.MEMORY_AGENT_END_AFTER ?? 0)
let info = null
let closed = false
let turns = 0
let session = null
/** The prompt in flight, settled when its result comes back. */
let current = null

/** What the server pushes: into the thread, then into the resume state. */
const deliver = (entry) => {
if (closed) return
host.emit(entry.event)
// Right after the emit: a daemon restarted from here resumes after it.
// Never the key: it comes back from env at every launch.
host.saveResume({ sessionId: session.id, lastSeq: entry.seq })
if (entry.event.type !== 'result') return
const stopReason = entry.event.payload.stop_reason
const settle = current
current = null
settle?.resolve({ stopReason })
if (stopReason !== 'end_turn') return
turns += 1
if (endAfter > 0 && turns >= endAfter) {
const reason = /** @type {'completed' | 'failed' | 'cancelled'} */ (env.MEMORY_AGENT_END ?? 'completed')
setTimeout(() => {
if (closed) return
host.end(reason === 'failed'
? { reason, message: 'the agent closed the session for maintenance' }
: { reason })
}, 0)
}
}

return {
get info() { return info },

async start() {
// Real driver: authenticate and open (or reopen) the remote session here,
// never in openSession. A rejection is shown in the mission's thread.
if (env.MEMORY_AGENT_FAIL === 'start') throw new Error('the agent refused the API key')
if (resume) {
// Real driver: reconnect to the run the saved state names, and ask
// for the events after lastSeq — the `result` of a turn that ended
// while the daemon was down included.
const known = typeof resume.state.sessionId === 'string' ? sessions.get(resume.state.sessionId) : undefined
if (!known) throw new Error('the agent no longer knows this session')
session = known
const lastSeq = Number(resume.state.lastSeq) || 0
info = { sessionId: session.id, capabilities: CAPABILITIES, resumed: true }
// Takes over from the driver of the daemon that died.
session.listener = deliver
for (const entry of session.log.slice(lastSeq)) deliver(entry)
return info
}
session = serverSession(spawn.resumeSession ?? `memory-${randomUUID()}`)
session.listener = deliver
info = { sessionId: session.id, capabilities: CAPABILITIES, resumed: Boolean(spawn.resumeSession) }
// Real driver: send spawn.systemPrompt with the session request.
host.emit(initEvent(session.id))
// From now on a restarted daemon can find this session again, even in
// the middle of the first turn.
host.saveResume({ sessionId: session.id, lastSeq: session.log.length })
return info
},

prompt(text) {
if (closed) return Promise.reject(new Error('the session is closed'))
if (!info) return Promise.reject(new Error('the session is not started'))
if (current || session.turn) return Promise.reject(new Error('a turn is already running'))
return new Promise((resolve, reject) => {
const turn = { resolve, reject }
current = turn
if (env.MEMORY_AGENT_FAIL === 'prompt') {
setTimeout(() => {
if (current !== turn) return
current = null
reject(new Error('the agent dropped the turn (HTTP 502)'))
}, delay)
return
}
// Real driver: post the message; the events come back on the stream
// (SSE, WebSocket) and go through deliver, the result last.
runTurn(session, text, delay)
})
},

async cancel() {
// Real driver: ask the server to stop the run, then let its stream end.
if (session && stopTurn(session)) return
if (!current) return
const { resolve } = current
current = null
host.emit(resultEvent('cancelled'))
resolve({ stopReason: 'cancelled' })
},

close() {
// Real driver: abort the open requests and cancel the remote run — a
// close is a stop. A daemon that dies closes nothing, which is what
// lets the next one reattach. Nothing is emitted afterwards.
if (closed) return
closed = true
if (session && session.listener === deliver) {
session.listener = null
if (session.turn) clearTimeout(session.turn)
sessions.delete(session.id)
}
if (current) {
current.reject(new Error('the session was closed'))
current = null
}
},
}
}

/** @type {import('@timon-ai/sdk/provider').RemoteDialect} */
const memoryDialect = {
id: 'memory-remote',
/** No process: the session is all there is. */
transport: 'remote',
/** A session id given back reopens the conversation. */
sessionResumable: true,
sessionStore: 'global',
/** This fictitious agent runs no tools, so it asks nothing. A real agent
* that asks should declare 'protocol' and call host.requestPermission. */
permissionModel: 'none',
rateLimitReporting: 'none',
hostedConnectors: false,

openSession(args) {
return createDriver(args)
},

Replace the model selector​

Source: examples/plugin-model-picker. Manifest ID: example-model-picker. Minimum API: 13.

API 13. One registration in the single model.picker slot replaces the model selector of the whole interface: the chat composer, the task form and the agent editor. The component receives no props; it reads the value, onChange, the inherit entry and the mode from useModelPicker(), and the models from useBackendCatalog(). It keeps the three rules Timon enforces: an account that cannot run is shown but not choosable, the inherit entry hands back null, and a value whose provider left the catalogue stays shown and disabled. Remove the plugin and Timon's own picker comes back.

Permissions: none. Read the related guide.

timon plugin add ./examples/plugin-model-picker installs this package on the target instance.

From examples/plugin-model-picker/lib/client.js, line 110 (excerpt):

export function apply(ctx) {
ctx.slots.register({ slot: 'model.picker' }, FlatModelPicker)
}

Usage without fabricated zeroes​

Source: examples/plugin-quota-monitor. Manifest ID: quota-monitor. Minimum API: 6.

The monitor combines host storage/settings with topbar, widget and settings surfaces. This function computes risk only from known finite measurements. Unknown usage remains null. The full template also preserves the last known reading during transient failures. Timon ships this package built in and enabled, so an installed copy with the same manifest ID is ignored: to experiment, copy the folder and change timon.id first.

Permissions: usage:read, storage, settings. Read the related guide.

timon plugin add ./examples/plugin-quota-monitor installs this package on the target instance.

From examples/plugin-quota-monitor/lib/index.js, line 27 (excerpt):

export function usageRisk(usage) {
if (!usage) return null
const values = Object.values(usage.windows ?? {})
.filter((window) => window && Number.isFinite(window.utilization))
.map((window) => window.utilization)
if (usage.extra?.is_enabled && Number.isFinite(usage.extra.utilization)) values.push(usage.extra.utilization)
return values.length ? Math.max(...values) : null
}

A speech-to-text engine​

Source: examples/plugin-dictation. Manifest ID: example-dictation. Minimum API: 14.

API 14. Two stand-in engines in the dictation.engine slot, one per runtime a third party may use: example-page runs in the window and sends the voice nowhere (egress: none); example-daemon sends blocks of audio to the plugin's own routes through ctx.api (egress: daemon) and returns the text when the user stops. Neither recognises a word: each reports how much voiced audio it heard. Timon opens the microphone, picks the language and owns the draft; an engine only receives 16 kHz mono samples and reports events. The install sheet lists where each engine sends the voice, and both engines then appear under Settings › Interface › Voice dictation. dictation-models.json is a pack with one real model for Timon's own engine, offered under From plugins in the same settings. The folder also carries a conformance test built on @timon-ai/sdk/testing; files keeps it out of the package.

Permissions: dictation:engine. Read the related guide.

timon plugin add ./examples/plugin-dictation installs this package on the target instance.

From examples/plugin-dictation/lib/client.js, line 31 (excerpt):

export function createPageEngine() {
return {
id: 'example-page',
label: 'Example engine (in this window)',
languages: { codes: ['en', 'fr'], mode: 'forced' },
interim: 'live',
async availability() {
// A real engine answers `needs-setup` until its model is downloaded, with a reason the user can act on.
return { state: 'ready' }
},
async start({ language, signal, onEvent }) {
let voicedMs = 0
let ended = false
const end = () => {
ended = true
signal.removeEventListener('abort', onAbort)
onEvent({ type: 'stopped' })
}
const onAbort = () => { if (!ended) end() }
signal.addEventListener('abort', onAbort)
return {
feed(pcm) {
if (ended) return
// Your model: hand it the samples, report what it has so far.
if (rms(pcm) > VOICED_RMS) {
voicedMs += (pcm.length / SAMPLE_RATE) * 1000
onEvent({ type: 'partial', text: exampleTranscript(voicedMs, language) })
}
},
async stop() {
if (ended) return
const text = exampleTranscript(voicedMs, language)
if (text) onEvent({ type: 'final', text })
end()
},
cancel() {
if (!ended) end()
},
}
},
}
}

From examples/plugin-dictation/lib/index.js, line 35 (excerpt):

export function apply(ctx) {
/** id → { owner, recognizer, samples, touched } */
const sessions = new Map()
const router = ctx.router()

// Required even on an installation without accounts: the voice is personal.
router.use((_request, response, next) => {
if (!ctx.user()) return response.status(401).json({ error: 'Authentication required' })
next()
})

const sweep = (now) => {
for (const [id, session] of sessions) if (now - session.touched > SESSION_IDLE_MS) sessions.delete(id)
}
/** The caller's own dictation, or null: another account's id answers 404, like an unknown one. */
const own = (request) => {
const session = sessions.get(String(request.params.id))
return session && session.owner === ctx.user()?.id ? session : null
}

router.get('/status', (_request, response) => {
response.json({ ready: true })
})

Owner-only live updates​

Source: examples/plugin-realtime. Manifest ID: example-realtime. Minimum API: 4.

API 4. An authenticated route increments a per-account counter and publishes changed to that account only. The widget subscribes, groups bursts with a 100 ms debounce, reloads through its own API and unsubscribes on unmount. Open two tabs as the same account to see both update; another account receives nothing. The counter is in memory and resets when the plugin unloads.

Permissions: realtime:publish. Read the related guide.

timon plugin add ./examples/plugin-realtime installs this package on the target instance.

From examples/plugin-realtime/lib/index.js, line 16 (excerpt):

router.post('/increment', (_request, response) => {
const ownerUserId = ctx.user()?.id
if (!ownerUserId) return response.status(401).json({ error: 'Authentication required' })
const count = (counts.get(ownerUserId) ?? 0) + 1
counts.set(ownerUserId, count)
ctx.realtime.publish('changed', { count }, { ownerUserId })
response.json({ count })
})

From examples/plugin-realtime/lib/client.js, line 23 (excerpt):

const unsubscribe = clientCtx.realtime.subscribe('changed', () => {
clearTimeout(timer)
timer = setTimeout(() => { void reload() }, 100)
})
void reload()
return () => { active = false; clearTimeout(timer); unsubscribe() }
}, [])

Agents created from an external system​

Source: examples/plugin-agent-sync. Manifest ID: agent-sync-example. Minimum API: 7.

API 7. One Timon agent per profile of another system, created through ctx.agents: the call runs with the rights of the account that makes it, so the plugin's own permission is not a way around the account's. create never overwrites a name already taken, and update only reaches the agents this plugin created.

Permissions: agents:manage. Read the related guide.

timon plugin add ./examples/plugin-agent-sync installs this package on the target instance.

From examples/plugin-agent-sync/lib/index.js, line 20 (excerpt):

export async function syncAgents(ctx, profiles, defaults = {}) {
const prefix = defaults.prefix ?? 'sync';
const known = new Map((await ctx.agents.list()).map((a) => [a.name.toLowerCase(), a]));
/** @type {{ created: string[], updated: string[], skipped: string[] }} */
const report = { created: [], updated: [], skipped: [] };
for (const profile of profiles) {
const name = agentName(prefix, profile.id);
const fields = {
systemPrompt: profile.prompt,
role: defaults.role ?? 'research',
...(defaults.backend ? { backend: defaults.backend } : {}),
...(defaults.model ? { model: defaults.model } : {}),
};
const existing = known.get(name.toLowerCase());
if (!existing) {
await ctx.agents.create({ name, ...fields });
report.created.push(name);
} else if (existing.mine) {
await ctx.agents.update(existing.name, fields);
report.updated.push(existing.name);
} else {
report.skipped.push(existing.name);
}
}
return report;
}

const STATUS = { invalid: 400, forbidden: 403, not_found: 404, conflict: 409 };

/** @param {import('@timon-ai/sdk').PluginContext} ctx */

A TO DO connector (GitHub Issues)​

Source: examples/plugin-todo-connector. Manifest ID: todo-connector-github. Minimum API: 8.

API 8. Keeps the TO DO notebook and an issue tracker in step: a webhook in, and a durable, ordered, credentialed call out when an idea is done. It is a template of about sixty lines: copy the folder, change the manifest ID, then adapt the handlers. The manifest declares "auth": "github", so GitHub calls the plugin directly and Timon checks its X-Hub-Signature-256 signature; no relay.

To connect a repository:

  1. On the plugin's install sheet, tick the GITHUB_TOKEN secret and the host api.github.com: both are granted to this plugin alone (Secrets). If the vault has no GITHUB_TOKEN yet, create it from the sheet's link, then grant it from Secrets. Then open Settings › Plugins › GitHub Issues connector (template) and fill in Repository (owner/name); Name of the vault entry holding the GitHub token stays GITHUB_TOKEN unless you stored the token under another name, which you then grant yourself.
  2. Under Incoming webhooks, copy the address of issues and click Generate secret. Copy the secret: it is shown once.
  3. On GitHub, open the repository's Settings › Webhooks › Add webhook. Paste the address in Payload URL, choose Content type: application/json, paste the secret in Secret.
  4. Choose Let me select individual events, tick Issues only, and save. GitHub sends a ping first: the handler ignores it and Timon answers 200.
  5. Open an issue on GitHub: it appears in the TO DO notebook, linked to the issue. Closing the idea in Timon closes the issue.

Permissions: webhooks:receive, todos:read, todos:write, events:subscribe, http:outbound, secrets:read, settings. Read the related guide.

timon plugin add ./examples/plugin-todo-connector installs this package on the target instance.

From examples/plugin-todo-connector/lib/index.js, line 20 (excerpt):

export const name = 'todo-connector-github'

const PROVIDER = 'github'

/** @param {import('@timon-ai/sdk').PluginContext} ctx */
export function apply(ctx) {
const repository = () => String(ctx.settings.get('repository') ?? '')
const refOf = (issue) => `${repository()}#${issue.number}`

// 1 + 2: a ticket opened on GitHub becomes an idea, once.
ctx.webhooks.on('issues', async (request) => {
// GitHub's first call is a `ping` with no issue: nothing to do.
if (request.headers['x-github-event'] !== 'issues') return
/** @type {{ action?: string, issue?: { number: number, title: string, body?: string | null, html_url: string } }} */
const { action, issue } = request.json()
if (!issue) return
if (action === 'opened') {
await ctx.kanban.todos.create(
{ text: `#${issue.number} · ${issue.title}`, notes: issue.body ?? null, author: PROVIDER },
[{ provider: PROVIDER, ref: refOf(issue), url: issue.html_url, label: `#${issue.number}` }],
)
}
// 5: a ticket closed on GitHub closes its idea.
if (action === 'closed') {
const idea = await ctx.kanban.todos.links.findByRef(PROVIDER, refOf(issue))
if (idea) await ctx.kanban.todos.close(idea.id) // origin = this plugin
}
})

// 3 + 4: an idea closed in Timon closes its issue on GitHub.
ctx.events.on('todo_done', async (event) => {
if (event.origin.kind === 'plugin' && event.origin.id === ctx.id) return // our own echo
const link = event.todo.links.find((l) => l.provider === PROVIDER)
if (!link) return
const number = link.ref.slice(link.ref.lastIndexOf('#') + 1)
await ctx.outbox.post(
`https://api.github.com/repos/${repository()}/issues/${number}`,
{ state: 'closed' },
{
method: 'PATCH',
key: `todo:${event.todo.id}`, // one lane per idea: outcome, then closure
auth: { secret: String(ctx.settings.get('tokenSecret') ?? 'GITHUB_TOKEN') },
headers: { accept: 'application/vnd.github+json' },
},
)
})
}

A shipped connector, written with the public SDK​

Source: examples/plugin-todo-connector-discord. Manifest ID: todo-connector-discord. Minimum API: 6.

API 6. The Discord connector Timon ships (off by default): tickets and replies in, outcomes out, files saved after a magic-byte check. It uses the public SDK only, so a GitHub, Jira or Slack connector can follow the same shape. Each ticket starts an investigation, which is a mission: the operator adds todo-connector-discord to pluginPolicy.spawn.approved (Permissions); until then the idea is created, an action card asks for the approval, the settings page names the ticket waiting, and the bot's next retry starts it. The excerpt shows the incoming handler and the registration.

Permissions: webhooks:receive, todos:read, todos:write, events:subscribe, kanban:write, http:outbound, secrets:read, storage, settings, triggers:disable. Read the related guide.

timon plugin add ./examples/plugin-todo-connector-discord installs this package on the target instance.

From examples/plugin-todo-connector-discord/lib/index.js, line 168 (excerpt):

async function onWebhook(ctx, request) {
const event = parseInbound(request.text)
await bump(ctx, (s) => ({ received: (s.received ?? 0) + 1, lastAt: Date.now(), lastType: event.type ?? event.kind }))
if (event.kind === 'invalid') {
ctx.log.warn(`webhook ${request.id} ignored: ${event.reason}`)
return
}
if (event.kind === 'ignore') return
if (event.kind === 'reply') await onReply(ctx, event)
else await onCreated(ctx, event)
}

/** One outcome or closure for the bot, queued behind the idea's earlier ones. */

From examples/plugin-todo-connector-discord/lib/index.js, line 201 (excerpt):

export function apply(ctx) {
ctx.webhooks.on('events', (request) => onWebhook(ctx, request))
ctx.events.on('todo_resolved', (event) => send(ctx, event, resolvedBody(event, Date.now())))
ctx.events.on('todo_done', (event) => send(ctx, event, doneBody(event, Date.now())))

const router = ctx.router()

router.get('/status', async (_request, response) => {
const settings = settingsOf(ctx)
const stats = await readStats(ctx)
const last = stats.lastOutbox ? await ctx.outbox.get(stats.lastOutbox.id) : undefined
response.json({
outbound: { workerUrl: Boolean(settings.workerUrl) },
// What the section's form shows: effective values, none of them a secret (`signingSecret` is a vault entry NAME).
settings: {
workerUrl: settings.workerUrl,
signingSecret: settings.signingSecret,
referencePattern: ctx.settings.get('referencePattern') ?? '',
investigationAgent: settings.agent,
project: settings.project,
silentInvestigation: settings.silent,
},
received: stats.received ?? 0,
lastAt: stats.lastAt ?? null,
lastType: stats.lastType ?? null,
awaitingApproval: stats.awaitingApproval ?? null,
lastOutbox: last && stats.lastOutbox ? { kind: stats.lastOutbox.kind, state: last.state, attempts: last.attempts, error: last.last_error } : null,
legacy: legacyTriggers(await ctx.triggers.list(), settings.workerUrl).map((t) => ({ id: t.id, name: t.name })),
})
})

// Retires the hand-made triggers this plugin replaces. A gesture, not an
// activation side effect: `discord-events` must keep receiving until the
// owner has seen this path receive too, hence the refusal while nothing came in.
router.post('/retire-legacy', async (request, response) => {
const settings = settingsOf(ctx)
const stats = await readStats(ctx)
if (!((stats.received ?? 0) > 0) && request.query?.force !== '1') {
response.status(409).json({ error: 'nothing has been received on the new path yet' })
return
}
const retired = []
for (const trigger of legacyTriggers(await ctx.triggers.list(), settings.workerUrl)) {
if (await ctx.triggers.disable(trigger.id)) retired.push({ id: trigger.id, name: trigger.name })
}
ctx.log.info(`retired ${retired.length} hand-made trigger(s): ${retired.map((t) => t.name).join(', ')}`)
response.json({ retired })
})

return () => { /* handlers and routes are released with the context */ }
}

A per-user connection​

Source: examples/plugin-user-connection. Manifest ID: user-connection-example. Minimum API: 7.

API 7. Each account registers its own service URL and key; the plugin reads that account's connection and calls the service once an administrator approved the address. The key lives in the daemon's vault and is never shown again.

Permissions: connections:register, connections:users:read, http:outbound. Read the related guide.

timon plugin add ./examples/plugin-user-connection installs this package on the target instance.

From examples/plugin-user-connection/lib/index.js, line 31 (excerpt):

export function apply(ctx) {
const type = `${ctx.id}:service@1`;

ctx.connections.registerType({
name: 'service', version: 1,
label: 'Example service',
description: 'An HTTP service reached with a bearer key.',
configSchema,
fields: [{ key: 'workspace', label: 'Workspace', placeholder: 'default' }],
secrets: [{ name: 'apiKey', label: 'API key', required: true }],
// The settings "Test" button. `context.fetch` stays on this connection's
// origin and goes through the daemon's SSRF guard.
async test(context) {
const key = await context.secret('apiKey');
const response = await context.fetch(endpoint(context.connection.url, 'health'), {
headers: { authorization: `Bearer ${key}` }, signal: context.signal,
});
return { ok: response.ok, message: response.ok ? 'Service reachable' : `Service answered ${response.status}` };
},
});

// GET /api/plugins/user-connection-example/status — for the CALLER only.
const router = ctx.router();
router.get('/status', async (_req, res) => {
const user = ctx.user();
if (!user) { res.status(401).json({ error: 'sign in' }); return; }
const connections = await ctx.connections.forUser(user.id);
const usable = connections.find((c) => c.type === type && c.enabled && c.approval === 'approved');
if (!usable) { res.json({ connected: false, pending: connections.some((c) => c.approval === 'pending') }); return; }
const key = await ctx.connections.userSecret(user.id, usable.id, 'apiKey');
// Present only with `http:outbound` in the manifest.
if (!ctx.fetch) { res.status(500).json({ error: 'http:outbound is not granted' }); return; }
try {
// Reachable because an approved connection opened this origin.
const response = await ctx.fetch(endpoint(usable.url, 'health'), { headers: { authorization: `Bearer ${key}` } });
res.json({ connected: true, status: response.status });
} catch (error) {
// A refusal names the rule, never the key.
res.status(502).json({ connected: true, error: error instanceof Error ? error.message : 'unreachable' });
}
});
}

Business events, workflows and messaging​

Source: examples/plugin-webhook-bridge. Manifest ID: webhook-bridge. Minimum API: 3.

API 3 registers a business event, an uppercase executor, a signal trigger and an authenticated transport. The receive handler verifies the timestamp and HMAC before accepting an external ID. The full send handler uses a per-fragment idempotency key and checkpoint. Configure connections with vault secret names and operator policy before sending traffic; no AI provider is needed for the uppercase executor.

Permissions: transports:register, connections:read, connections:write, missions:spawn, missions:read, secrets:read, http:outbound, events:register, events:publish, workflows:triggers:register, workflows:executors:register. Read the related guide.

timon plugin add ./examples/plugin-webhook-bridge installs this package on the target instance.

From examples/plugin-webhook-bridge/lib/index.js, line 33 (excerpt):

ctx.businessEvents.register({ name: 'message.received', version: 1, payloadSchema: textSchema });
ctx.workflows.registerExecutor({
name: 'uppercase', version: 1, configSchema: emptySchema, outputPorts: ['out'],
execute: ({ input }) => ({ output: { text: textSchema.parse(input).text.toUpperCase() }, port: 'out' }),
});
ctx.workflows.registerTrigger({
name: 'signal', version: 1, configSchema: topicSchema,
start(context) { signals.add(context); return () => { signals.delete(context); }; },
});

From examples/plugin-webhook-bridge/lib/index.js, line 56 (excerpt):

async receive(context, request) {
const stamp = request.headers['x-timon-timestamp'] ?? '';
const signature = request.headers['x-timon-signature'] ?? '';
if (!/^[0-9]{10}$/.test(stamp) || Math.abs(Date.now() / 1000 - Number(stamp)) > 300 || !/^[a-f0-9]{64}$/.test(signature)) {
throw new Error('Invalid signature envelope');
}
const signing = await context.secret('signing');
if (!signing || signing.length < 24) throw new Error('Signing secret unavailable');
const expected = createHmac('sha256', signing).update(`${stamp}.`).update(request.body).digest();
if (!timingSafeEqual(expected, Buffer.from(signature, 'hex'))) throw new Error('Invalid signature');
const message = object(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(request.body)));
if (message.room !== context.connection.config.room) throw new Error('Room not allowed');
// Inbound URLs, credentials and identity fields never select the callback.
return { externalId: text(message.id), text: text(message.text, 64_000), replyContext: { room: text(message.room) } };
},