electric-sql / electric-sql/electric
# RFC: Parsed Composer Input for Electric Agents
- Dominant language
- TypeScript
- Stars
- 10.4k
- Forks
- 375
- Avg merge
- 3d 1h
- Merged PRs (30d)
- 18
Description
## 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:
```ts
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:
```json
{
"type": "composer_input",
"payload": {
"source": "/quickstart",
"nodes": [
{
"kind": "slash_command",
"start": 0,
"end": 11,
"raw": "/quickstart",
"name": "quickstart"
}
]
}
}
```
Another example:
```json
{
"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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```json
{
"type": "composer_input",
"payload": {
"source": "/quickstart",
"nodes": [
{
"kind": "slash_command",
"start": 0,
"end": 11,
"raw": "/quickstart",
"name": "quickstart"
}
]
}
}
```
6. server persists it to the entity stream
7. wake fires
8. entity handler processes it
For example, a `/quickstart` handler path could scan the node list and inject the quickstart instructions into context:
```ts
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:
```ts
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:
```text
/pr-review 123 in /worktree be sure to check @Branch see @Horton
```
The UI could send:
```json
{
"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
Contributor guide
Assessment
This issue has not been assessed yet.