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.yamlA 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
| Field | Type | Required | Purpose |
|---|---|---|---|
name | string | yes | Local agent name. It must not contain :; runtime references use <app-id>:<name>. |
description | string | yes | Short summary of what the agent does. |
tier | large | medium | small | yes | A capability tier that doesn't name a provider. The daemon maps it to a real model for the current provider. |
reasoningEffort | low | high | xhigh | no | How much reasoning the model should use. Defaults to high. |
systemPromptPrefix | string | yes | The app agent's complete base system prompt. |
tools | string[] | yes | Built-in tool allowlist (e.g. Read, Write, Edit, Grep, Glob, WebSearch, WebFetch). |
actions | string[] | no | Action allowlist; defaults to none. "*" opens every action; otherwise list each canonical <app-id>:<name> action ID. |
permissionMode | acceptEdits | bypassPermissions | default | yes | File-edit permission mode. |
maxTurns | number | no | Cap on conversation turns. Recommended >= 30. |
allowedSubagents | string[] | no | Canonical <app-id>:<name> IDs of sub-agents this agent may summon. |
outputSchema | JSON Schema | no | See 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 platformmainorchestrator 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.
| Group | Types | What they mean |
|---|---|---|
| Lifecycle | turn_start, turn_end, session_init | Edge events that describe the stream itself. |
| Content | thinking, text, tool_use, tool_result, structured_output, result, error | Lasting blocks made within a turn. |
| Transient | text_delta | A 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); optionalturnPhaseiscommentary(in-turn narration) orfinal(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 optionalaccountingcarrying provider token usage. At most one final block (resultorerror) per agent per turn.error— the final failure block (error), with optionalaccounting.
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
| Use | When |
|---|---|
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.