Building
Agent crews
Ship a team, not a tool — create and heal agents, shape how they delegate, and govern what they say to each other.
On AgentParley, agents work as a crew: a lead hands scoped tasks to specialists, each specialist runs on its own model with its own persona and tools, reports back, and the lead integrates the results. Delegation, reporting back, reach control and "don't answer the human until your crew has reported" are enforced by the platform, not by prompt tricks.
A plugin can plug into every part of that:
| You want to… | Use |
|---|---|
| Install a whole team of agents and keep it healthy | manifest.agents + host.agents.* + lifecycle events |
| Put each member on the right model | host.models.list() |
| Teach the lead how to conduct | middleware.systemPrompt + a skill + commands |
| Start work from code — spawn a subagent, message a colleague | host.session.subagent.spawn, host.session.peer.send |
| Drive long missions | host.session.todo.*, host.session.notify.schedule |
| See, rewrite or block what agents say to each other | middleware.interAgentMessage |
| Shape todos and timers agents set themselves | middleware.todoAdded, notifyScheduled, notifyDelivered |
The reference crew plugin is Oh My OpenAgents — seven agents, a doctrine, five commands and three skills. See the walkthrough.
How crews work (the platform side)
- Agents are real. Each has a name, a soul (persona), instructions, its own model, a thinking level, and a reply mode. A spawned subagent runs as the target agent — its persona, tools and model.
- One level of hierarchy. A main agent (the lead) can have subagents under it. A subagent can be group-only callable: only its lead and sibling specialists can reach it. Only main agents can front a chat channel.
- Delegation is asynchronous. A lead's
start_subagentreturns immediately with the child's id; the lead keeps working; the child's result arrives later as a message that wakes the lead. Independent specialists run in parallel (up to the plan's concurrent-run limit — Free 1, Solo 3, Entrepreneur 10; beyond that they queue). - Nobody is left hanging. A subagent reports with
report_to_caller. If it forgets, its final message is delivered anyway (in the default auto reply mode); if it fails, the lead is told. - The lead doesn't answer early. While any live subagent still owes it a report, or it has open todos, the lead's answer to its human is held back.
- Colleagues keep a thread.
message_agentkeeps one standing conversation between two agents and reuses it, so a colleague remembers earlier exchanges. Its reply comes back automatically. - Reach is controlled. Each agent's owner decides which agents it may call. Nothing crosses accounts.
- Depth. By default a subagent cannot spawn subagents of its own.
The built-in tools your doctrine should name: list_agents, start_subagent, start_self_subagent,
report_to_caller, message_agent, send_message_to_session, read_session, list_agent_sessions,
close_subagent, reply_to_human, todo_write / todo_list / todo_remove / todo_snooze,
notify_session_in / notify_list / notify_cancel.
Declare the crew — manifest.agents
// infrastructure/plugins/oh-my-openagents/index.ts
globalThis.manifest = {
agents: CREW.map((member) => ({ key: member.key, name: member.name })),
};
Before installing, the user sees "Adds 7 agents" and the console checks the plan has room (agents per
account: Free 2, Solo 5, Entrepreneur 25). At most 20 entries; each key is your stable id for that member. A new
version that adds an agent waits for the owner's OK like any new access.
Manage the crew — host.agents
Permission agentApi:agents.read (for list) and agentApi:agents.write (for create/update/delete), derived
from the calls. host.agents works without a chat — in pluginInstalled, for example.
Your plugin only ever sees its own agents. An agent the user created, or another plugin's agent, is "not found" — its existence never leaks.
create — get-or-create by key
host.agents.create({
key: string; // your stable id, 1–200 chars — e.g. "oracle"
name: string;
description?: string;
modelId?: string; // from host.models.list(); omit = the account default model
soul?: string;
instructions?: string;
isSubagent?: boolean; // default false (a main agent)
parentAgentId?: string | null; // a main agent YOU own; requires isSubagent: true
isGroupOnlyCallable?: boolean; // only its lead and siblings may call it
replyMode?: "implicit" | "auto" | "all"; // default "auto"
thinkingLevel?: "off" | "low" | "medium" | "high"; // default "off"; applied when the model supports it
}): Promise<PluginAgent & { wasCreated: boolean }>
Calling create again with the same key returns the existing agent, untouched (wasCreated: false) — the
user's edits are never overwritten. That makes re-running your setup safe under at-least-once delivery. To
re-assert something deliberately, call update.
Reply modes: implicit delivers only what the agent sends explicitly; auto (default) delivers its final message
to whoever is owed an answer; all streams every message as it is written.
list
host.agents.list(): Promise<{ agents: {
id: string; slug: string; name: string; description: string; modelId: string | null; enabled: boolean;
key: string | null; // the key you created it with — reconcile on THIS, never on name
isSubagent: boolean; parentAgentId: string | null; isGroupOnlyCallable: boolean;
}[] }>
update
host.agents.update({
agentId: string;
name?; description?; modelId?; soul?; instructions?; enabled?: boolean;
isSubagent?: boolean;
parent?: { parentAgentId: string | null }; // omit = unchanged; { parentAgentId: null } = no parent
isGroupOnlyCallable?; replyMode?; thinkingLevel?;
}): Promise<PluginAgent>
Omitted fields keep their value. parent replaces the whole parent selection, so "clear the parent" (null) and
"leave it" (omitted) are different.
delete
host.agents.delete({ agentId: string }): Promise<void>
Rejects (409) if the user made that agent the account default; the agent then stays.
Users stay in charge
Your agents show in the console badged as coming from your plugin. The user can edit or delete them like any other agent. Uninstalling your plugin deletes them, with every chat they were in.
Pick models — host.models.list()
host.models.list(): Promise<{ models: {
id: string; modelIdentifier: string; displayName: string;
inputUsdPer1M: number | null; outputUsdPer1M: number | null; cachedUsdPer1M: number | null;
}[] }>
The account's enabled models with the user's prices. Permission agentApi:models.read. Oh My OpenAgents walks a
preference list per member — flagship models for the planner, cheap fast ones for the navigator — and falls back
to the account default:
// infrastructure/plugins/oh-my-openagents/src/models.ts
export function resolveModel(member: CrewMember, models: PluginModel[]): ResolvedModel {
for (const pattern of member.preferredModels) {
const match = models.find((model) => pattern.test(model.modelIdentifier) || pattern.test(model.displayName));
if (match) {
return { modelId: match.id, family: detectFamily(match.modelIdentifier), label: match.modelIdentifier };
}
}
return { family: "generic", label: "account default" };
}
Self-healing crews
Run one idempotent reconcile from three lifecycle events — install, update, and every new chat — and the crew converges to your definition: missing members re-created, drifted ones re-asserted, retired ones removed.
// infrastructure/plugins/oh-my-openagents/src/reconcile.ts (abridged)
export async function reconcileCrew(): Promise<void> {
const [{ agents }, { models }] = await Promise.all([host.agents!.list(), host.models!.list()]);
const ownedByKey = new Map(agents.map((agent) => [agent.key, agent]));
const desiredKeys = new Set(CREW.map((member) => member.key));
const provision = async (member: CrewMember, placement: Placement): Promise<string> => {
const resolved = resolveModel(member, models);
const shared = {
name: member.name,
description: member.description,
soul: member.soul,
instructions: member.baseInstructions + REPORT_CONTRACT + overlayFor(resolved.family),
replyMode: member.replyMode,
...(resolved.modelId ? { modelId: resolved.modelId } : {}),
};
const existing = ownedByKey.get(member.key);
if (!existing) {
const created = await host.agents!.create({
key: member.key,
...shared,
isSubagent: placement.isSubagent,
...(placement.isSubagent ? { parentAgentId: placement.parentAgentId } : {}),
});
return created.id;
}
await host.agents!.update({
agentId: existing.id,
enabled: true,
...shared,
isSubagent: placement.isSubagent,
parent: { parentAgentId: placement.isSubagent ? placement.parentAgentId : null },
});
return existing.id;
};
// The lead first, so its id is the parent every specialist points at.
const orchestrator = CREW.find((member) => member.category === "orchestration");
const specialists = CREW.filter((member) => member.category !== "orchestration");
const orchestratorId = orchestrator ? await provision(orchestrator, { isSubagent: false }) : null;
for (const member of specialists) {
await provision(member, orchestratorId ? { isSubagent: true, parentAgentId: orchestratorId } : { isSubagent: false });
}
for (const agent of agents) {
if (agent.key !== null && !desiredKeys.has(agent.key)) {
await host.agents!.delete({ agentId: agent.id });
}
}
}
A design choice to make consciously: this reconcile re-asserts each member's definition on every run, so a
user's edits to a crew member's persona are replaced at the next chat start. If you'd rather let users customize
your agents, only create missing members and skip the update.
Teach the lead to conduct
Three layers, cheapest first:
An always-on doctrine via
middleware.systemPrompt, injected into the lead only (so specialists don't try to orchestrate recursively):// infrastructure/plugins/oh-my-openagents/index.ts globalThis.middleware.systemPrompt = async (context) => { const { agents } = await host.agents!.list(); const isOwnedCrewMember = agents.some((agent) => agent.id === context.agentId); return isOwnedCrewMember ? context.systemPrompt : context.systemPrompt + ORCHESTRATION_DOCTRINE; };Note that this doctrine reaches every agent on the account that isn't in the crew — middleware is account-wide.
A skill with the full method, loaded with
use_skillonly when the lead actually conducts.Commands that route a goal to a specialist:
/research <question>→ Librarian.
Give every specialist a report contract in its instructions — "report back … BY CALLING report_to_caller" — so results flow back through the platform's reply path.
Start work from plugin code — host.session
In a tool, a middleware hook, or a session lifecycle event, host.session acts on the current chat:
host.session.subagent.spawn({ targetAgentId?: string; task: string; isFireAndForget?: boolean })
: Promise<{ outcome: "created" | "forbidden" | "depthExceeded"; sessionId: string | null }>
host.session.peer.send({ sessionId: string; text: string })
: Promise<{ isDelivered: boolean; isCanceled: boolean; cancelReason: string | null }>
host.session.peer.get({ sessionId: string }): Promise<{ id: string; agentId: string; state: string }>
host.session.conversation.read({ sessionId?, limit?, cursor? }): Promise<{ messages, nextCursor }>
host.session.message.post({ text: string }): Promise<{ messageId: string }>
subagent.spawnnever blocks: it returns the child's session id and the child reports back through the normal path, waking the chat.targetAgentIdomitted = a copy of the current agent. It follows the same rules as thestart_subagenttool — reach and depth — and says so inoutcomerather than throwing.peer.senddelivers into another visible session and wakes it. A target that isn't visible, is closed, or was vetoed by middleware comes back asisDelivered: false— data, not an error.- Neither discharges the current chat's own obligation to answer its human.
Written for these docs — a tool that fans a question out to a named specialist of your crew:
globalThis.tools.ask_librarian = {
description: "Hand a research question to the Librarian specialist. Returns immediately; the answer arrives later.",
parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
handler: async (input: { question: string }) => {
const { agents } = await host.agents!.list();
const librarian = agents.find((agent) => agent.key === "librarian");
if (!librarian) return { error: "the Librarian isn't installed on this account" };
const spawn = await host.session!.subagent.spawn({ targetAgentId: librarian.id, task: input.question });
return spawn.outcome === "created"
? { status: "started", sessionId: spawn.sessionId }
: { status: spawn.outcome };
},
};
Long missions — todos and timers
Open todos keep an agent working: while any remain, the platform re-wakes the agent turn after turn. A timer wakes it later with a note. Plugins can set both:
host.session.todo.create({ texts: string[] }): Promise<{ todos: { id: string; text: string }[] }>
host.session.todo.list(): Promise<{ todos: { id: string; text: string }[] }>
host.session.todo.update({ todoId: string; text: string }): Promise<{ id: string; text: string }>
host.session.todo.complete({ todoId: string }): Promise<void> // marks it done
host.session.todo.delete({ todoId: string }): Promise<void> // removes it ("never mind")
host.session.notify.schedule({ message: string; delaySeconds: number })
: Promise<{ notifyId: string | null; fireAt: string | null; isCanceled: boolean; cancelReason: string | null }>
Permissions agentApi:todo.read / agentApi:todo.write / agentApi:notify.write, derived from the calls. A timer
blanked by another plugin's notifyScheduled middleware comes back { notifyId: null, isCanceled: false } — a
legitimate no-op.
And you can shape what agents set themselves with the todoAdded, notifyScheduled and notifyDelivered hooks
(Middleware).
Watching and shaping crew traffic
middleware.interAgentMessage sees every message one agent's conversation sends into another's — peer messages,
subagent reports (isSubagentReport: true), automatic reply-backs, and peer.send from plugins. It doesn't see the
initial task text start_subagent hands a new subagent. Return new text, or veto.
Written for these docs — keep a crew from leaking credentials to each other, and refuse reports that claim success without evidence:
globalThis.middleware.interAgentMessage = async (context, tools) => {
const text = context.text.replace(/\b(sk|pk|ghp)_[A-Za-z0-9_]{16,}\b/g, "[secret removed]");
if (context.isSubagentReport && /\bdone\b/i.test(text) && !/evidence|verified|test(s)? pass/i.test(text)) {
host.log("warn", `report from agent ${context.senderAgentId} rejected: no evidence`);
return tools.cancel("report claims done without evidence");
}
return text;
};
Be careful with vetoes on reports: the sending subagent isn't told why, and its lead keeps waiting. Rewriting (for example appending "[unverified — ask for evidence]") is usually kinder than cancelling.
Safety rails
- Hop-depth fuse. If a hook reacts to crew traffic by sending more crew traffic, each generation is a hop; past
4 hops from one original event,
host.sessionis withheld for that generation. Loops die quietly. - Your agents only.
host.agentsnever reaches agents your installation didn't create. - Model-provider plugins can't do any of this — they may not spawn, message, notify, write todos or create agents.
Things to know before you design
- One level of hierarchy: lead → specialists. No crews of crews.
- Default subagent depth is 1: a specialist can't spawn its own subagents.
- Parallelism is capped by the plan's concurrent runs; beyond it, work queues rather than fails.
- A large crew needs a plan with room: seven agents plus the account's default agent needs Entrepreneur.