Agents

Define LLM agents that belong to your app and call them from your own code.

Agent-readable version: Markdown · llms.txt

An app-private agent is an LLM agent set up in an app's YAML. By convention you call your own agents through agentRunner.run, and reach agents you don't own through the platform system:summon action. Each agent states a capability tier, an allowlist of built-in tools, an allowlist of actions it may call, and, if you want, a JSON Schema for structured output. The daemon turns the tier into a real model, checks every tool and action call against the allowlists, and streams the agent's work back to the caller as a series of AgentMessage events.

Use an app-private agent when a task needs open-ended LLM thinking over context the app gathers — research, triage, summarizing, multi-step planning — rather than one fixed operation (which should be an action).

Declaring an agent

Agents are set up as YAML files under src/agents/<name>.yaml and listed in app.yaml:

# app.yaml
formatVersion: 2
id: research-app
agents:
  - agents/planning.yaml
  - agents/coding.yaml

A single agent file:

name: researcher
description: Researches a given topic and produces a structured summary
tier: medium                   # Capability tier: large | medium | small
reasoningEffort: high          # Optional: low | high | xhigh; defaults to high
permissionMode: acceptEdits    # acceptEdits | bypassPermissions | default
maxTurns: 30                   # Optional: max conversation turns; recommend >= 30
tools:                         # Built-in tool allowlist
  - Read
  - WebSearch
  - WebFetch
actions:                       # Action allowlist; "*" opens everything
  - system:fetch_channel_history
  - research-app:notes_create
allowedSubagents:              # Optional: sub-agents this agent may `summon`
  - assistant:explore
systemPromptPrefix: |
  You are the researcher agent. Read the input topic and context, run
  WebSearch when needed, and finish by calling submit_output.

The checks are strict: unknown fields in an agent YAML are rejected at pack/install. The table lists the commonly used fields; the schema also accepts mcpServers, networkDiscovery, codeBacked, and the legacy model: opus|sonnet|haiku spelling of tier.

Fields

FieldTypeRequiredPurpose
namestringyesLocal agent name. It must not contain :; runtime references use <app-id>:<name>.
descriptionstringyesShort summary of what the agent does.
tierlarge | medium | smallyesA capability tier that doesn't name a provider. The daemon maps it to a real model for the current provider.
reasoningEffortlow | high | xhighnoHow much reasoning the model should use. Defaults to high.
systemPromptPrefixstringyesThe app agent's complete base system prompt.
toolsstring[]yesBuilt-in tool allowlist (e.g. Read, Write, Edit, Grep, Glob, WebSearch, WebFetch).
actionsstring[]noAction allowlist; defaults to none. "*" opens every action; otherwise list each canonical <app-id>:<name> action ID.
permissionModeacceptEdits | bypassPermissions | defaultyesFile-edit permission mode.
maxTurnsnumbernoCap on conversation turns. Recommended >= 30.
allowedSubagentsstring[]noCanonical <app-id>:<name> IDs of sub-agents this agent may summon.
outputSchemaJSON SchemanoSee Structured output below.

systemPromptPrefix

For an app-owned agent, systemPromptPrefix is the complete base prompt. Rome does not add its core Charter, guardian identity, command-line guidance, installed-app catalog, or other core-agent instructions. This keeps the agent's role and behavior under the app developer's control.

Rome still appends contracts that implement features the app explicitly uses, such as the submit_output instructions for outputSchema or a handback. A caller-provided contextSuffix is also appended for that session. Put any platform context or tool-use guidance your agent needs directly in systemPromptPrefix.

tier

tier is a capability tier that doesn't name a provider (large, medium, or small). The daemon turns it into a real model based on the active provider, so the agent YAML never names a model itself.

reasoningEffort

reasoningEffort controls how much reasoning the selected model should use. It is independent of tier: a small or medium agent still uses high effort unless you set this field. Valid values are low, high, and xhigh; omitting the field defaults it to high.

tools and actions allowlists

The tools list controls which built-in tools the agent can use. The actions list controls which actions the agent can call through LLM tool-use during a run. These are the same allowlists the agent session uses to check every tool call — the agent picks the allowed actions itself as it thinks; you do not set its tool-call order by hand in code.

actions: ["*"] gives access to all actions. Be careful with broad permissions: pairing permissionMode: bypassPermissions with tools that reach the outside world, like WebFetch, is a bad idea. Keep bypassPermissions for agents you fully trust, or that are read-only or sandboxed; otherwise use acceptEdits.

permissionMode

Controls how file-edit permissions work for the agent:

  • default — ask for permission as usual.
  • acceptEdits — auto-accept file edits.
  • bypassPermissions — skip permission checks completely. The platform main orchestrator runs in this mode, but it is a trusted agent with full access.

maxTurns and allowedSubagents

maxTurns caps how many conversation turns the agent may take; set it to >= 30 for any agent that does real work. allowedSubagents uses canonical agent IDs for the sub-agents this agent may summon — for example, coding:planning, assistant:assistant, and assistant:explore.

Running an agent

From a backend action, get agentRunner off the dependency box and call run. It returns an AsyncIterable<AgentMessage> you should read all the way to the end. The turn runs whether or not you consume the stream — leaving the loop early just abandons it, so you stop observing and miss the final result / structured_output events.

import {
  createAppLogger,
  type Action,
  type ActionConfig,
  type ActionResult,
  type AgentRunnerInterface,
  type AppActionRuntimeDeps,
} from "@rome-os/app-runtime";

const log = createAppLogger("research-and-summarize");

interface Deps {
  agentRunner: AgentRunnerInterface;
}

export function createAction(
  config: ActionConfig,
  deps: AppActionRuntimeDeps<Deps>,
): Action {
  const { agentRunner } = deps;

  return {
    config,
    inputSchema: {
      type: "object",
      properties: { topic: { type: "string" } },
      required: ["topic"],
    },
    async execute(args): Promise<ActionResult> {
      let finalText = "";
      let sessionId: string | undefined;

      for await (const msg of agentRunner.run({
        agentName: "research-app:researcher",
        prompt: `Research the topic: ${args.topic as string}`,
      })) {
        switch (msg.type) {
          case "turn_start":
            sessionId = msg.sessionId; // capture to resume the session later
            break;
          case "text":
            // streaming/in-turn text — forward to a UI if desired
            break;
          case "result":
            finalText = msg.content;
            break;
          case "error":
            log.error("researcher failed", { error: msg.error });
            return { status: "error", error: msg.error };
        }
      }

      return { status: "ok", data: { summary: finalText } };
    },
  };
}

RunParams takes agentName and prompt, plus optional fields: channelThreadKey, threadContext, sessionId (resume an existing session), contextSuffix, sharedContext (extra context shown to the agent), and workingDir.

The AgentMessage event types

Every event in the stream falls into exactly one of three groups. Check msg.type.

GroupTypesWhat they mean
Lifecycleturn_start, turn_end, session_initEdge events that describe the stream itself.
Contentthinking, text, tool_use, tool_result, structured_output, result, errorLasting blocks made within a turn.
Transienttext_deltaA live preview of a text block still being written; never saved.

Lifecycle. A turn's stream has a clear start and end. turn_start is the first event and carries turnId, sessionId, and userPrompt — read these here, not from session_init. turn_end is the last event and carries turnId, status (completed | interrupted | error), and durationMs. Each stream has exactly one turn_start/turn_end pair (its own); a sub-agent's turn_start/turn_end are never passed into the parent stream, though sub-agent content is, tagged with an agent field (StreamAgentMessage), and so is a sub-agent's session_init. session_init describes the session (sessionId, optional systemPrompt, projectPath).

Content. These are the lasting blocks:

  • thinking — reasoning content (content).
  • text — narration or answer text (content); optional turnPhase is commentary (in-turn narration) or final (closing answer).
  • tool_use — a tool call the model made (id, tool, input).
  • tool_result — the result of a tool call (toolUseId, tool, output).
  • structured_output — a checked structured payload (see below).
  • result — the final block: the agent's answer (content), with optional accounting carrying provider token usage. At most one final block (result or error) per agent per turn.
  • error — the final failure block (error), with optional accounting.

Transient. text_delta (content) is a piece-by-piece preview of a text block still being written. The full text block still comes after, so readers that only care about whole blocks (trace, saving, accounting) must ignore it. Only providers that support partial output send it.

Reading to the end

A typical read: grab sessionId from turn_start if you want to resume the session, pass along or collect content blocks, and take the final answer from the result block's content. Always loop over the whole stream — leaving the for await loop early doesn't stop the turn; it keeps running underneath while you no longer see its events, including the final result.

Structured output

When the agent should return structured JSON instead of free text, set an outputSchema (a JSON Schema) on the agent. The daemon gives the agent a built-in submit_output tool; the agent must call it with a payload that matches the schema. Submissions that don't match come back to the agent as tool errors until they do.

name: sentinel
description: Triages incoming messages from untrusted senders
tier: small
permissionMode: bypassPermissions
tools: [Read, Glob, Grep]
actions: ["*"]
outputSchema:
  type: object
  required: [decision, reason]
  additionalProperties: false
  properties:
    decision:
      type: string
      enum: [REPLY, ESCALATE, IGNORE]
    reason:
      type: string
systemPromptPrefix: |
  You are a triage agent. For each message decide REPLY / ESCALATE / IGNORE.
  Once decided, call submit_output with your decision and reasoning.

The structured_output stream event

After a submit_output payload passes the schema check and is accepted, the agent session sends a structured_output event whose payload is the checked object. Because the check has already passed, readers can trust the payload — it is not the same as the raw tool_use event for the submit_output call, which is only the model's proposal.

for await (const msg of agentRunner.run({
  agentName: "research-app:sentinel",
  prompt,
})) {
  if (msg.type === "structured_output") {
    const { decision, reason } = msg.payload as {
      decision: "REPLY" | "ESCALATE" | "IGNORE";
      reason: string;
    };
    // `decision` is guaranteed to be one of the enum values
  }
}

Reusing agents

agentRunner.run is the right entry point for your own app's agents. To call an agent your app does not own — including another app's agent — go through the platform system:summon action instead of calling agentRunner.run yourself:

const result = await appContext.runAction("system:summon", {
  agentName: "assistant:explore",
  prompt: "Give an overview of the skills and actions relevant to this task.",
  // sessionId?  — optional, to continue an existing session
});

summon vs agentRunner.run

UseWhen
agentRunner.run({ agentName, prompt, ... })Running an agent your app declares. Pass its canonical <your-app-id>:<local-name> ID. Streams AgentMessage events you read directly; use it when you need the live event stream (passing along tokens, reading structured_output, grabbing sessionId).
appContext.runAction("system:summon", { agentName, prompt, sessionId? })Calling any agent you do not own (another app's agent, or a platform agent). agentName is canonical. Returns one ActionResult — check status before reading data.

Don't call agentRunner.run on agents that aren't yours; reach them through system:summon. An agent's ability to summon another agent is also limited by its allowedSubagents allowlist.

On this page