Skip to main content

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"]
}
PermissionSurfaceSensitive
agents:managectx.agents.delete, rename, setAvatar, and memoryScope on update
agents:docs:read, agents:docs:writectx.agents.docs.*: an agent's identity documents (SOUL.md…), which are its instructionsyes
messages:send, messages:readctx.messages: the inbox of the agents you createdread: yes
skills:read, skills:writectx.skillswrite: yes
mcp-servers:read, mcp-servers:writectx.mcpServerswrite: yes
backends:readctx.backends.list: the backends and models available
agents:read + events:subscribeagent_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:manage to delete an agent, skills:manage to 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 as plugin:<id>. A plugin identity cannot be answered, so a request needs as. 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, pick and reply work on the inbox of your agents only, and show the messages the calling account owns. recv takes status (default pending), from and limit (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; recv is 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. A stdio server (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's pluginPolicy.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.