Agents, messages, skills and MCP servers
A plugin that connects Timon to another system often has to manage what an agent is: give it a picture and a personality, rename or retire it, send it what comes in, read what it says back, hand it a skill or a tool server. This page covers those calls. Creating and updating agents (ctx.agents.create, update) is in Permissions and the plugin-agent-sync template. Requires apiVersion: 10.
"timon": {
"id": "acme-support",
"apiVersion": 10,
"permissions": ["agents:manage", "agents:docs:write", "messages:send", "messages:read", "events:subscribe", "agents:read"]
}
| Permission | Surface | Sensitive |
|---|---|---|
agents:manage | ctx.agents.delete, rename, setAvatar, and memoryScope on update | |
agents:docs:read, agents:docs:write | ctx.agents.docs.*: an agent's identity documents (SOUL.md…), which are its instructions | yes |
messages:send, messages:read | ctx.messages: the inbox of the agents you created | read: yes |
skills:read, skills:write | ctx.skills | write: yes |
mcp-servers:read, mcp-servers:write | ctx.mcpServers | write: yes |
backends:read | ctx.backends.list: the backends and models available | |
agents:read + events:subscribe | agent_created, agent_updated, agent_deleted events |
Sensitive means the call changes or reads what an agent loads on every mission: its instructions, its tools, its conversations. Declare only what you use.
Who you can reach
Every call is bounded by who is acting:
- In a request (a route of your plugin, a webhook, a callback), the signed-in account must hold the same right on its own account (for example
agents:manageto delete an agent,skills:manageto write a skill). An anonymous request is refused. - In a job (a timer, a listener), your plugin acts as itself and reaches only what it created. What belongs to a person or to another plugin answers
not_found, as if it did not exist. - A request from a superadmin lifts the "created by this plugin" limit. It never lifts the product's own rules: a product skill, a server Timon injects and the main orchestrator's seat stay out of reach.
Errors carry a code: invalid, not_found, forbidden and, for agents, conflict.
Delete, rename, picture, documents
await ctx.agents.rename('hermes.support', 'hermes.billing') // the new PluginAgent
const url = await ctx.agents.setAvatar('hermes.billing', { mime: 'image/png', data: base64 })
await ctx.agents.update('hermes.billing', { memoryScope: 'AGENT' }) // NONE, SESSION, AGENT or GLOBAL
await ctx.agents.docs.put('hermes.billing', 'SOUL.md', 'You stay calm.')
await ctx.agents.delete('hermes.billing') // true
Only agents your plugin created. The main orchestrator is never deleted (conflict); a name taken, whatever its case, is a conflict. A picture is image bytes sent inline ({ mime, data } or { dataUrl }), up to 2 MB, or { clear: true }; a URL or a path is refused. Documents are *.md files at the agent's root, up to 2 MB, never empty (use docs.rm); system-prompt.md can be read and written but not removed.
Messages
// Tell one of your agents something that came in from outside
await ctx.messages.send({ to: 'hermes.support', payload: { ticket: 'OPS-12' } })
// Let your agent ask another one, and read the answer later
const ask = await ctx.messages.send({ to: 'research.bot', kind: 'request', payload: 'Summarise OPS-12', as: 'hermes.support' })
const inbox = await ctx.messages.recv('hermes.support') // pending messages
await ctx.messages.pick(inbox[0].id) // mark one as read
await ctx.messages.reply(inbox[0].id, 'On it.') // answers as hermes.support
- A message is written as one of your own agents (
as) or asplugin:<id>. A plugin identity cannot be answered, so arequestneedsas. You never write as an agent you did not create, a superadmin included. - A job writes only to your own agents. A request may write to any agent its account may talk to (an account can opt out of inter-agent communication). When the sender is one of your agents that sits in a team, the team rule applies to it.
recv,pickandreplywork on the inbox of your agents only, and show the messages the calling account owns.recvtakesstatus(defaultpending),fromandlimit(1 to 200).- The answer to a request sent by one of your agents lands in that agent's inbox: read it with
recv. There is no "message received" event;recvis the interface.
Skills
await ctx.skills.put('howto-acme', '# How to Acme\n\nStep one…')
const skill = await ctx.skills.get('howto-acme') // { id, title, content, mine, product, … }
await ctx.skills.rm('howto-acme')
A skill is loaded into the missions of the agents it is attached to, so the rules are strict. You write by id, never a product skill, never an allowed_tools line in the header, and you rewrite or remove only the skills you created. Attaching a skill to an agent stays with the user.
MCP servers
await ctx.mcpServers.put('acme-search', {
name: 'Acme search',
transport: { type: 'http', url: 'https://mcp.acme.test/sse', headers: { Authorization: 'Bearer …' } },
})
const server = await ctx.mcpServers.get('acme-search') // headers: { Authorization: '***' }
Not to be confused with ctx.mcp, which hands an agent on another machine Timon's own tools (Timon tools for a remote agent). A server declaration is a command on the host or an endpoint every attached agent calls, so:
- Only
http(s)servers. Their URL cannot name the machine Timon runs on or a link-local address, whoever the caller is. Astdioserver (a command on the host) needs a superadmin as the caller of the request, a job never can, and its binary must also be allowed by the operator'spluginPolicy.exec.allow: it is empty by default, and Timon's own CLIs and generic launchers (node,sh…) are never allowed. - You change or remove only the servers you declared. Servers Timon injects, connectors discovered by the harness and servers added by a person stay out of reach.
- Secrets are always read back masked (
***), a superadmin's request included. Writing the masked value back keeps the stored secret as long as the server still points at the same place; if its URL changed, the write is refused. - OAuth sign-in is a step with the user in the browser: it is never written through this API.
Backends
const { backends, defaultBackend } = await ctx.backends.list()
const claude = backends.find((b) => b.id === 'claude') // { id, label, models: [{ slug, … }], defaultModel, … }
The catalogue the Agents page shows the caller. A request sees the subscription accounts on its access list; a job sees none. Use it before ctx.agents.create({ backend, model }).
Agent events
ctx.events.on('agent_updated', (ev) => {
if (ev.origin.kind === 'plugin' && ev.origin.id === ctx.id) return // my own write
// ev.agent: { name, backend, model, project, role, hasSystemPrompt, createdBy }
// ev.changed: ['model'], ['system_prompt'], ['doc:SOUL.md'], ['name'] (then ev.previousName)
})
agent_created, agent_updated and agent_deleted fire whoever wrote the agent: the Agents page, the CLI, an agent, another plugin. You receive the ones for the agents you created, with the identity of the agent as the write left it (for a deletion, as it was before) and the origin of the write: { kind: 'user' | 'agent' | 'plugin' | 'system', id }. Handlers run one at a time, in order. Host half only.