Skip to content

# RFC: Parsed Composer Input for Electric Agents #4382

@KyleAMathews

Description

@KyleAMathews

Status

Draft

Summary

This RFC adds a standardized parsed composer-input convention to Electric Agents.

Concretely:

  • composer_input becomes a well-known inbox message type
  • the payload always preserves the original source text
  • the payload preserves the original source text and may also carry parsed structure as an ordered node list
  • slash_command is a first-class composer node kind within the parsed input
  • files, symbols, branches, and similar rich references can become additional node kinds over time
  • entities can still declare slash commands up front and discover more dynamically at runtime
  • UIs can autocomplete commands, show rich chips, and send both raw text and parsed structure
  • handler-side helpers remain responsible for interpretation and behavior

Motivation

Today, users can already ask an agent to do things in natural language, and the agent will often do the right thing (if they're provided with a tool).

But modern coding-assistant UIs increasingly allow richer composition than a single slash command followed by plain text. Users may mix:

  • multiple slash commands
  • plain text between them
  • file references
  • symbol references
  • branch references
  • other structured tokens

In practice, the input is starting to behave more like source text in a tiny composer language than like a single command invocation.

That means the runtime should not standardize only a singular slash_command payload. It should standardize a broader parsed input convention that:

  • preserves the original source text
  • may also carry parsed structure that the UI already knows about
  • leaves semantics to handlers and helper libraries

This RFC does not introduce the general ability to send structured payloads to handlers; the typed inbox model already supports that. Instead, it defines a shared composer_input convention for UIs and handlers that want interoperable parsed composer data. The more deeply integrated addition in this RFC is the slash-command declaration and discovery system, including EntityDefinition.slashCommands and db.collections.slashCommands.

Deterministic means:

  • the UI and runtime know what structured tokens were recognized
  • handlers need not infer everything from free-form text
  • parsed structure can be transported faithfully alongside the raw source

Discoverable means:

  • UIs can show available slash commands
  • users can browse and autocomplete them
  • commands can carry descriptions and argument hints
  • rich references like files and symbols can be represented explicitly

We do not need a new transport abstraction for this work. The existing send + typed inbox message model is already the right base.

What is missing is a standard parsed composer-input model.

Goals

  • Add first-class parsed composer input support to Electric Agents
  • Standardize the composer_input inbox message type
  • Preserve raw source text alongside parsed structure
  • Support multiple structured tokens, including slash commands, in one user input
  • Support additional node kinds over time, such as files and symbols
  • Keep slash-command declaration and discovery as first-class capabilities
  • Give UIs structured metadata they can build against
  • Keep execution behavior in the handler
  • Preserve room for related typed message families like rpc

Non-goals

  • Redefining send
  • Replacing the existing typed inbox message model
  • Building a centralized global command registry
  • Fully specifying RPC request/response semantics in this RFC
  • Defining the full composer UI in this RFC
  • Rich argument typing/validation in v1
  • Nested composer parsing in v1
  • Fully specifying every future composer node kind in this RFC

Current state

Electric Agents already has the important pieces:

  • typed sends to an entity inbox
  • inbox schema validation by message type
  • handler-side processing of inbox messages

What we do not have yet is a first-class standardized parsed composer-input convention.

Proposal

To avoid ambiguity, this RFC uses the following terminology:

  • EntityDefinition.slashCommands = the static slash-command declarations authored with the entity definition
  • db.collections.slashCommands = the built-in StreamDB collection that stores the current slash-command list for that entity
  • ComposerInputPayload = the typed payload sent when a UI submits parsed composer input
  • ComposerNode = one parsed node inside that payload, such as text, slash command, file, or symbol

The runtime seeds db.collections.slashCommands from EntityDefinition.slashCommands and keeps it up to date as entity handlers register, overwrite, or unregister dynamic commands.

1. Standardize composer_input as a first-class inbox message type

Parsed composer input should be represented on the wire as a typed inbox message.

Suggested request shape:

export type SendRequest = {
  from?: string
  type: string
  payload: unknown
  mode?: 'immediate' | 'queued' | 'paused' | 'steer'
  position?: string
}

UIs will typically send parsed input with mode: 'immediate', but composer_input is not a special exception to the inbox transport.

UIs may continue sending plain text or other structured payloads as they do today. composer_input is the standardized convention for cases where a UI wants to send preserved source text plus parsed composer nodes.

Suggested message example:

{
  "type": "composer_input",
  "payload": {
    "source": "/quickstart",
    "nodes": [
      {
        "kind": "slash_command",
        "start": 0,
        "end": 11,
        "raw": "/quickstart",
        "name": "quickstart"
      }
    ]
  }
}

Another example:

{
  "type": "composer_input",
  "payload": {
    "source": "/pr-review 123 in /worktree see @Branch",
    "nodes": [
      {
        "kind": "slash_command",
        "start": 0,
        "end": 10,
        "raw": "/pr-review",
        "name": "pr-review"
      },
      {
        "kind": "text",
        "start": 10,
        "end": 18,
        "raw": " 123 in "
      },
      {
        "kind": "slash_command",
        "start": 18,
        "end": 27,
        "raw": "/worktree",
        "name": "worktree"
      },
      {
        "kind": "text",
        "start": 27,
        "end": 32,
        "raw": " see "
      },
      {
        "kind": "branch",
        "start": 32,
        "end": 39,
        "raw": "@Branch",
        "name": "Branch"
      }
    ]
  }
}

Suggested payload shape:

export type ComposerInputPayload = {
  source: string
  nodes?: ComposerNode[]
}

export type ComposerNode =
  | TextNode
  | SlashCommandNode
  | FileNode
  | SymbolNode
  | BranchNode

export type BaseNode = {
  kind: string
  start: number
  end: number
  raw: string
}

export type TextNode = BaseNode & {
  kind: 'text'
}

export type SlashCommandNode = BaseNode & {
  kind: 'slash_command'
  name: string
}

export type FileNode = BaseNode & {
  kind: 'file'
  path: string
}

export type SymbolNode = BaseNode & {
  kind: 'symbol'
  name: string
}

export type BranchNode = BaseNode & {
  kind: 'branch'
  name: string
}

V1 should keep this simple:

  • the UI or client SDK always preserves the full source text
  • the UI or client SDK is responsible for parsing composer input into recognized node kinds before sending composer_input
  • the UI may also attach parsed nodes it has already resolved; when omitted, handlers receive raw source only
  • the node list is ordered and flat
  • command-specific argument parsing and higher-level interpretation remain handler-owned
  • only slash_command nodes have a corresponding declaration and discovery system in v1; other node kinds are parse-only and handler-interpreted

V1 validation is structural, not semantic: the runtime validates payload shape, but higher-level meaning is handled by the entity at runtime.

2. Treat slash commands as one composer node kind

Slash commands are one first-class token kind within parsed composer input.

Instead, slash commands become one ComposerNode variant:

type SlashCommandNode = BaseNode & {
  kind: 'slash_command'
  name: string
}

This matches the direction of richer composer UIs:

  • multiple slash commands may appear in one input
  • plain text may appear between them
  • files, symbols, branches, and other references may appear alongside them

The transport should preserve those facts rather than collapsing everything into a single command payload.

In v1, only slash_command nodes have a corresponding declaration and discovery system. Other node kinds are parse-only: the UI emits them, the runtime transports them structurally, and handlers interpret them. No registry or global validation model is defined for those node kinds in this RFC.

3. Add slashCommands to EntityDefinition

Entities still need a static slash-command declaration so UIs and handlers can reason about available commands structurally.

This declaration is discovery metadata, distinct from invocation payloads. It defines the slash-command vocabulary an entity exposes, and the runtime materializes it into db.collections.slashCommands.

Suggested definition shape:

export type SlashCommandDefinition = {
  name: string
  description?: string
  arguments?: Array<{
    name: string
    type: 'string' | 'number' | 'boolean'
    required?: boolean
    description?: string
  }>
}

SlashCommandDefinition.arguments is discovery metadata for the UI. It does not imply a standardized invocation-time argument object in v1.

UIs can use db.collections.slashCommands for command pickers, autocomplete, argument hints, and help text. Entity handlers can use the static declaration as the baseline command set.

Entities may optionally declare slash commands up front via EntityDefinition.slashCommands.

Potential shape:

export type EntityDefinition = {
  // existing fields
  slashCommands?: SlashCommandDefinition[]
}

These statically declared slash commands are written into db.collections.slashCommands.

Examples:

  • /quickstart
  • /init
  • app-specific commands defined by an entity author

Static slashCommands should land in the same change as composer_input transport support.

4. Support dynamic slash-command discovery from handlers

Not all slash commands need to be predeclared.

Entity handlers can discover additional slash commands dynamically at runtime.

Examples:

  • commands exposed by skill packages
  • commands available only in certain environments or states
  • commands derived from runtime capabilities

This RFC leaves dynamic discovery to handlers.

To support that, the TypeScript runtime should expose helper APIs that entity handlers can use to manage dynamic slash commands.

Suggested runtime helper surface:

ctx.slashCommands.get(name)
ctx.slashCommands.register(command)
ctx.slashCommands.unregister(name)
ctx.slashCommands.list()

register(command) upserts by name; registering an existing command replaces its current materialized definition.

Suggested behavior:

  • db.collections.slashCommands is the built-in collection for the current slash-command list
  • static commands from EntityDefinition.slashCommands and dynamic commands from runtime helpers both write into that collection
  • UIs read it through the existing sync/query path
  • register(command) upserts by name
  • if a dynamic command and static command share the same name, the dynamic command wins

5. Keep execution in the handler

The runtime owns transport and structural validation; handlers own interpretation.

A SKILL loader example

One possible helper pattern is:

export const handler = async (ctx: EntityHandlerContext) => {
  await skillsLoader(ctx)

  // continue normal handler flow
}

The wake inspection happens inside skillsLoader(ctx), not in the handler after the call.

A helper following that pattern could:

  • discover skill-backed slash commands
  • publish them through the runtime slash-command helpers
  • inspect ctx.wake for a newly delivered composer_input
  • scan the parsed nodes for slash-command nodes
  • if present, inject the relevant SKILL.md or package-defined content into context

Such a helper should be idempotent and cheap on repeated wakes so it can be safely called at the top of the handler.

6. Put the current wake on handler context

Because wakes can come from many sources, composer-input helpers cannot assume that every wake is a new composer invocation.

Handlers therefore need a reliable way to detect whether the current wake delivered composer_input and to read its payload.

The current runtime model passes wake: WakeEvent as a separate handler argument. This RFC proposes moving that onto handler context as ctx.wake. That wake-shape change is part of this RFC, not a separate follow-on.

A goal of this change is a tighter typed ctx.wake overall, ideally as a discriminated union across wake sources. Handler code should not need to peel apart an untyped payload object just to tell that parsed composer input arrived.

The desired shape is something closer to:

type HandlerWake = InboxWake | SpawnWake | ScheduleWake | ObservationWake

type InboxWake = {
  type: 'inbox'
  message: {
    message_type: string
    payload: unknown
  }
}

With a discriminated union like that, composer-input detection becomes straightforward:

if (ctx.wake.type === 'inbox' && ctx.wake.message.message_type === 'composer_input') {
  const payload = ctx.wake.message.payload as ComposerInputPayload
  // inspect payload.source and payload.nodes
}

The exact final ctx.wake union can still be refined by the implementer, but this RFC should aim for a discriminated wake type with an inbox variant at least this direct.

7. Keep parsed composer structure flat in v1

As the input model evolves beyond a single slash command, the runtime will likely need to carry both raw source text and parsed structure for slash commands, files, symbols, branches, and other rich composer tokens.

V1 should keep that parsed structure flat, not nested.

The transport should preserve:

  • the original source text
  • the ordered parsed nodes
  • source spans for each node
  • the raw text for each node

A flat model is enough for the current use cases:

  • multiple slash commands in one input
  • plain text between commands
  • file, symbol, branch, and similar references mixed into the same input

This RFC does not propose nested parsing in v1. Associations like “this slash command is the primary action” or “this branch reference modifies that command” should be derived later by handlers or helper libraries, not baked into the transport grammar yet.

This keeps the parser and wire format simpler while leaving room for richer syntax later if the product genuinely needs it.

Example flows

Parsed composer input with command-triggered behavior

  1. entity declares /quickstart in EntityDefinition.slashCommands
  2. runtime materializes it into db.collections.slashCommands
  3. UI reads that collection and shows it in the slash command picker
  4. user invokes it
  5. UI sends:
{
  "type": "composer_input",
  "payload": {
    "source": "/quickstart",
    "nodes": [
      {
        "kind": "slash_command",
        "start": 0,
        "end": 11,
        "raw": "/quickstart",
        "name": "quickstart"
      }
    ]
  }
}
  1. server persists it to the entity stream
  2. wake fires
  3. entity handler processes it

For example, a /quickstart handler path could scan the node list and inject the quickstart instructions into context:

if (ctx.wake.type === 'inbox' && ctx.wake.message.message_type === 'composer_input') {
  const payload = ctx.wake.message.payload as ComposerInputPayload

  for (const node of payload.nodes) {
    if (node.kind === 'slash_command' && node.name === 'quickstart') {
      await ctx.insertContext({
        type: 'skill',
        title: 'Quickstart instructions',
        content: quickstartSkillMarkdown,
        metadata: {
          source: 'composer_input',
          nodeKind: 'slash_command',
          command: 'quickstart',
          rawInput: payload.source,
        },
      })
    }
  }
}

A single-command /search postgres logical replication handler path could scan for the search slash-command node, derive the query from the remaining source text, call a search API, and inject the result into context with explicit provenance metadata:

if (ctx.wake.type === 'inbox' && ctx.wake.message.message_type === 'composer_input') {
  const payload = ctx.wake.message.payload as ComposerInputPayload

  for (const node of payload.nodes ?? []) {
    if (node.kind === 'slash_command' && node.name === 'search') {
      // This simple extraction only works for a single-command input.
      // Multi-command association should be handled by a helper that
      // groups or slices the relevant text region more carefully.
      const query = payload.source.slice(node.end).trim()
      const result = await searchDocs(query)

      await ctx.insertContext({
        type: 'search_result',
        title: `Search result for: ${query}`,
        content: result.summary,
        metadata: {
          source: 'composer_input',
          nodeKind: 'slash_command',
          command: 'search',
          query,
          url: result.url,
          rawInput: payload.source,
        },
      })
    }
  }
}

Mixed composer input with multiple commands and references

A richer input might look like:

/pr-review 123 in /worktree be sure to check @Branch see @Horton

The UI could send:

{
  "type": "composer_input",
  "payload": {
    "source": "/pr-review 123 in /worktree be sure to check @Branch see @Horton",
    "nodes": [
      {
        "kind": "slash_command",
        "start": 0,
        "end": 10,
        "raw": "/pr-review",
        "name": "pr-review"
      },
      {
        "kind": "text",
        "start": 10,
        "end": 18,
        "raw": " 123 in "
      },
      {
        "kind": "slash_command",
        "start": 18,
        "end": 27,
        "raw": "/worktree",
        "name": "worktree"
      },
      {
        "kind": "text",
        "start": 27,
        "end": 46,
        "raw": " be sure to check "
      },
      {
        "kind": "branch",
        "start": 46,
        "end": 53,
        "raw": "@Branch",
        "name": "Branch"
      },
      {
        "kind": "text",
        "start": 53,
        "end": 58,
        "raw": " see "
      },
      {
        "kind": "symbol",
        "start": 58,
        "end": 65,
        "raw": "@Horton",
        "name": "Horton"
      }
    ]
  }
}

The handler can then decide, from the flat ordered node list, that:

  • /pr-review is the primary action
  • /worktree acts as an execution modifier
  • @Branch attaches branch context
  • @Horton attaches symbol context

Those semantics are intentionally derived at the handler/helper layer rather than imposed by the transport grammar.

Multi-word references may require explicit UI-level selection or chip-based insertion so the node boundaries are unambiguous. This RFC does not standardize that parsing UX in v1.

Dynamic skill-backed slash command

  1. handler-side skillsLoader(ctx) discovers commands from skill packages
  2. it registers those commands via ctx.slashCommands.register(...)
  3. UI reads db.collections.slashCommands and surfaces one of those commands
  4. user invokes /quickstart
  5. inbox receives composer_input
  6. entity handler runs await skillsLoader(ctx) again on the invocation wake
  7. because the loader is idempotent, it cheaply confirms registration state and inspects ctx.wake
  8. loader recognizes the relevant slash-command node and injects the corresponding skill content
  9. handler continues with enriched context

If a dynamic command temporarily overrides a static command with the same name, unregistering the dynamic command should reveal the static declaration again in the effective command list.

Runtime responsibilities

The runtime has three jobs in this design:

  1. transport and persist composer_input messages
  2. materialize the slash-command registry that UIs read
  3. give handlers typed wake and slash-command-management helpers

What the runtime should do

On send:

  • validate the top-level send fields such as type, payload, mode, and position
  • if type === 'composer_input', validate payload against ComposerInputPayload
  • treat composer_input as a built-in standardized inbox message type rather than requiring each entity to redefine its schema
  • persist the typed inbox message to the entity stream
  • wake the target entity

On wake delivery:

  • preserve a reliable inbox-to-wake mapping
  • expose that mapping through a typed ctx.wake, so handlers can tell when the current wake corresponds to new composer_input

For slash-command discovery:

  • materialize statically declared slash commands into the built-in db.collections.slashCommands collection
  • expose TypeScript runtime helper APIs for handlers to get, register, unregister, and list dynamic slash commands in that same collection
  • reconcile static and dynamic commands into the single effective slash-command list that UIs query

What the runtime should not do

The runtime should not need to:

  • understand skill package layout
  • centrally interpret composer input
  • own the discovery logic for dynamic slash commands
  • decide what a slash command, file reference, or branch reference means beyond basic shape validation and delivery

Error handling

Handlers should use the existing entity error/reporting path for unknown or failed commands in v1 rather than introducing a new composer-input-specific error transport.

UI responsibilities

UIs should be able to:

  • discover available slash commands by querying the synced db.collections.slashCommands collection
  • show command names and descriptions
  • prompt for or hint arguments where possible
  • parse composer input into a flat ordered node list where the UI has recognized rich tokens
  • preserve the full original source text
  • in chip-based UIs, serialize the visible composed input into a canonical source string
  • invoke parsed input as a structured composer_input message
  • react to slash-command creation and deletion over time via the synced collection
  • optionally attach parse-only references and other structured tokens alongside slash-command nodes

Codebase impact

Likely main touchpoints:

Shared/public types

  • packages/agents-server/src/electric-agents-types.ts
  • packages/agents-runtime/src/types.ts
  • packages/agents-runtime/src/skills/types.ts if skill metadata gains slash-command metadata
  • composer-input payload and node types

Entity/inbox schema

  • packages/agents-runtime/src/entity-schema.ts
  • packages/agents-runtime/src/define-entity.ts
  • entity definition types that would gain slashCommands

Send route / persistence

  • packages/agents-server/src/routing/entities-router.ts
  • packages/agents-server/src/entity-manager.ts

Wake/handler access

  • packages/agents-runtime/src/process-wake.ts
  • packages/agents-runtime/src/types.ts to add ctx.wake
  • packages/agents-runtime/src/context-factory.ts where handler context is constructed
  • inbox-to-wake mapping for composer_input detection
  • TypeScript runtime helper APIs on handler context for dynamic slash-command management

UI

  • packages/agents-server-ui/src/components/MessageInput.tsx
  • packages/agents-server-ui/src/lib/sendMessage.ts
  • slash-command picker/discovery UI
  • composer parser / chip-to-node serialization

Built-in agents / entity handlers

  • packages/agents/src/agents/horton.ts
  • handler-side helpers like skillsLoader(ctx)

Open questions

1. What should the discriminated ctx.wake union look like?

Composer-input helpers must be able to inspect ctx.wake and reliably distinguish composer-input invocations from other wake sources without digging through an untyped payload blob.

2. Do we need richer argument schemas in v1?

This is intentionally left open for the implementer. The default bias is no: source text plus handler-owned interpretation is enough to start unless a richer typed input model is clearly needed for a good UI and invocation experience. SlashCommandNode does not carry a standardized arguments object in v1; commands like /init my-app are interpreted from surrounding source text or adjacent nodes by handler-side logic.

3. When should the parser move beyond a flat node list?

The default bias is to keep parsed composer structure flat in v1. Nested structure should only be added if real product needs emerge that cannot be handled cleanly by an ordered node list plus handler-side interpretation.

Recommendation

Build parsed composer input on the existing typed inbox model.

Specifically:

  • standardize composer_input as a well-known inbox message type
  • preserve the full source text in every parsed composer payload
  • carry parsed structure as a flat ordered node list
  • treat slash commands as one first-class node kind among others
  • add slashCommands to EntityDefinition
  • allow entities to predeclare slash commands
  • materialize both static and dynamic slash commands into the built-in db.collections.slashCommands StreamDB collection
  • add TypeScript runtime helper APIs for entity handlers to manage dynamic slash commands in that collection
  • have UIs read that collection through the existing sync/query path
  • leave higher-level interpretation to handlers
  • put wake on handler context as ctx.wake
  • give inbox-triggered wakes a tighter typed shape
  • use the same typed inbox model for future typed message families like rpc
  • use helper patterns like skillsLoader(ctx) on top of those runtime APIs for skill-backed command behavior

This keeps the architecture simple:

  • no new transport abstraction
  • a single materialized slash-command collection for UI reads
  • raw source always preserved when a UI opts into composer_input
  • no standardized CLI-style argument grammar in v1
  • no nested composer grammar in v1
  • strong support for the richer UI composition model the product is moving toward

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions