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_subagent returns 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_agent keeps 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:

  1. 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.

  2. A skill with the full method, loaded with use_skill only when the lead actually conducts.

  3. 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.spawn never blocks: it returns the child's session id and the child reports back through the normal path, waking the chat. targetAgentId omitted = a copy of the current agent. It follows the same rules as the start_subagent tool — reach and depth — and says so in outcome rather than throwing.
  • peer.send delivers into another visible session and wakes it. A target that isn't visible, is closed, or was vetoed by middleware comes back as isDelivered: 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.session is withheld for that generation. Loops die quietly.
  • Your agents only. host.agents never 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.