# Agent crews

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](example-oh-my-openagents.md).

## 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 chat.** `message_agent` chooses where the message lands, in this order: the chat of the agent
  that messaged you this turn (so a reply goes back to whoever asked); else, if the target talks to an account user
  directly on a channel (a Slack DM, say), that chat, which is visible where the person reads it. With several such
  chats the most recently active one wins, and the optional `channel` argument picks the one on a named channel (it
  never falls back to another); else one standing thread kept between the two agents and reused. Only the standing
  thread's reply comes back automatically; in a channel chat or a reply, the target answers with `message_agent` when
  it needs to. So a crew prompt must not count on an automatic answer.
- **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`

```ts
// 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-z`, `0-9`, `_` and `-`, starting with a letter or digit, up to 64 characters) and each `name` is 1–100 characters. 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

```ts
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
  tools?: string[];              // the agent may use ONLY these; see below
}): 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`.

`tools` limits the agent to the tools you list: a built-in by bare name (`web_search`), your own tool by bare name,
another plugin's tool by its full `slug__tool` name. Passing it makes the agent's tool list managed by your plugin:
the console shows it read-only, the user can't change it, and every `update({ tools })` replaces the whole list.
`tools: []` means no tools at all, and listing an opt-in tool such as `decide` grants it. Names that match no
installed tool come back in `unresolvedTools` and are not granted, so re-apply `tools` in your setup (a name starts
working the next time setup runs after that tool's plugin is installed). On a `create` whose key already exists,
`tools` is ignored like every other field; 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`

```ts
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`

```ts
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?;
  tools?: string[];                            // omit = leave the tool list alone
}): 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`

```ts
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()`

```ts
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:

```ts
// 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.

```ts
// 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 every agent that is not part of the crew
   (so specialists don't try to orchestrate recursively):

   ```ts
   // 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 the plugin is installed on that isn't in the crew.

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**:

```ts
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.
- Permissions, derived from the calls: `agentApi:subagent.spawn` for `subagent.spawn`, `agentApi:peer.send` for
  `peer.send`, `agentApi:session.read.cross` for `peer.get` (and for `conversation.read` of another session),
  `agentApi:conversation.read` and `agentApi:message.post`.

Written for these docs — a tool that fans a question out to a named specialist of your crew:

```ts
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:

```ts
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](plugin-middleware.md)).

## 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:

```ts
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.
