Skip to content

Commands: the Action List

One registry of everything an app can do. Keys, menus, the palette, a CLI and a model over MCP all run the same action by id.

13 min read

An app has one list of the things a person or an agent can do in it, and every surface runs those things through that list. A key, a menu item, the command palette, a CLI, a script and a model talking MCP do not keep their own tables: they look an action up by id in the Action List and run it there. So the same id does the same thing whoever asks, and adding a command once makes it reachable from all of them.

Three pieces, all opt-in:

PieceWhereFeature
ActionList, Effect, ActionInfo, ActionEventarchitect::action (crates/architect/src/action_list.rs)architect/action-list
json_schema(shape)architect::schema (crates/architect/src/schema.rs)architect/action-list
MCP server over a listarchitect-mcp (features/mcp)http for the HTTP transport
Stage timings per actionarchitect_telemetry::timing (features/telemetry/src/timing.rs)native only

1. An action#

An action is a stable id plus metadata (ActionMeta: id, label, description, category, group, a display-only shortcut), an Effect, and the facet types it takes and gives back.

  • Id: AREA_VERB, screaming snake case (SESSION_GAIN_STAGE). The id is the contract: keymaps, menus, REAPER named commands and scripts store it. Don’t rename one.
  • Metadata comes from #[architect::actions]. Each #[action(...)] method gets a pub const <METHOD>: ActionMeta.
  • Effect tells a caller, a model above all, what running it does:
    • ReadOnly: reads only, safe to run any time.
    • Mutating: changes the app, undoably.
    • Destructive: an undo may not bring it back (deletes, writes to disk, renders over a file).
  • Args / Out are facet types (() for none). The list decodes the arguments before the handler sees them and describes both as JSON Schema, so a caller can find out exactly what to send.

2. Registering#

use architect::action::{ActionList, Effect};
use facet::Facet;

#[derive(Facet)]
struct Query {
    /// The track to read.
    track: String,
    limit: Option<u32>,
}

#[derive(Facet)]
struct Hits {
    times: Vec<f64>,
}

pub fn register(list: &ActionList) -> Result<(), ActionError> {
    list.register(&DRUMS_HITS, Effect::ReadOnly, |q: Query| {
        let n = q.limit.unwrap_or(10);
        Ok(Hits { times: hits(&q.track, n) })
    })?;
    list.set_enabled("DRUMS_HITS", || {
        project_open().then_some(()).ok_or_else(|| "no project is open".to_owned())
    })?;
    Ok(())
}
  • register::<A, R, _>(meta, effect, |A| -> Result<R, String>) adds a typed action. A second registration of an id is refused with ActionError::Duplicate and the first stays.
  • register_dynamic(meta, effect, params, result, handler) adds one whose handler is already JSON in, JSON out (an rpc method, a script).
  • register_with(meta, effect, |&ActionCaller, A| -> Result<R, String>) and register_async_with add an action whose handler is also handed who is calling (see Who is calling). R is any facet type or RawJson.
  • Existing #[architect::actions] traits plug in unchanged: ActionList implements ActionBackend, so the generated register_<trait>_actions(&list, imp) lands every action in the list (as Mutating, no args, no result). A duplicate id that comes this way is logged and kept out; validate() reports it.
  • set_enabled(id, || Result<(), String>) says when an action can run. Err(why) disables it: ActionInfo shows enabled: false and disabled_reason, and a run answers ActionError::Disabled.
  • set_checked(id, || bool) makes an action a toggle: ActionInfo shows checked: Some(on) (None for an action that is not one), the tick a menu draws beside it. The handler still does the flipping; the check only reads the state.
  • Keep one register(list) function per area module and call them all from one place at startup. The registry test builds the list and runs the list’s own checks:
#[test]
fn the_action_list_is_sound() {
    let list = ActionList::new();
    my_app::actions::register(&list).unwrap();
    list.validate().unwrap();          // unique ids + shortcuts, AREA_VERB ids, schemas are JSON objects
    list.probe_hostile_args().unwrap(); // `{}` never panics; malformed JSON is always refused
}

probe_hostile_args runs handlers, so build the list against test doubles, not a live app.

3. Running#

  • run_json(id, args) -> Result<Vec<u8>, ActionError> is the one door: JSON in, JSON out. An action that takes nothing accepts empty input or {}; one that gives nothing answers null. It runs as the app itself: the authorizer is not asked.
  • run(id) is what a key or a menu calls: no arguments.
  • run_json_as(principal, id, args) / run_as(principal, id) run for a caller from outside (a model, a peer). With an authorizer set (set_authorizer(engine)), PermissionEngine::authorize(principal, id) decides first; its default is check(principal, "action/<id>", "run"), so existing rule sets answer it.
  • infos() / info(id) describe the list: id, labels, category, group, shortcut, effect, enabled / disabled_reason, and params_schema / result_schema as JSON Schema text. infos_for(principal) / info_for(principal, id) show only what that caller may run.
  • ActionError is one of Unknown(id), BadArgs { expected, .. } (carries the params schema so the caller can correct itself), Failed { message }, Denied { reason }, Disabled { reason } or Duplicate(id).
  • A handler that panics is caught and answered as Failed with message "panicked: ..." (in panic = "unwind" builds, the default); the list keeps working.

Who is calling#

A handler registered with register_with / register_async_with gets an ActionCaller beside its arguments: the principal the call is made as, and one context value the door put on the call (the org a multi-tenant server acts in, a session). Doors that pass one are run_json_with(&caller, id, args) and run_json_async_with; every other door passes ActionCaller::app() (or ActionCaller::new(who) for run_json_as), with no context.

use architect::action::{ActionCaller, RawJson};

struct Org(Arc<OrgState>); // the app's own type

list.register_with(&TASKS_LIST, Effect::ReadOnly, |cx: &ActionCaller, args: ListArgs| {
    let Org(org) = cx.context::<Org>().ok_or("no org to act in")?;
    let rows: serde_json::Value = org.list_tasks(args)?;
    serde_json::to_vec(&rows).map(RawJson::from).map_err(|e| e.to_string())
})?;

let caller = ActionCaller::new(principal).with_context(Org(org));
list.run_json_with(&caller, "TASKS_LIST", br#"{"status":"open"}"#)?;
  • ActionCaller::app() is the trusted app: the authorizer is not asked. ActionCaller::new(principal) is checked against it before the handler runs. with_context(value) adds the context, context::<T>() reads it back by type; a second with_context replaces the first, so put what a call needs in one type.
  • A handler that needs a context must refuse without one: keys, menus and probe_hostile_args pass none.
  • An async handler is lent the caller while it builds its future: clone the caller (cheap, the context is shared) into the async move.
  • RawJson is JSON another serializer (serde_json) already wrote, answered as it is, with no result schema. It is checked to be one JSON value before it leaves the action. Arguments are still facet types, so the params schema comes from the type the call is decoded into. Wire types between services stay facet-only; RawJson is for an app whose replies are built with serde already.

Every run is one tracing span named action with action.id and action.ok. Stages marked inside it with architect_telemetry::timing::Stages (edit.detect_ms, …) land on that span. The Recorder layer keeps finished spans in a ring buffer the app reads back with timing::recent(n). Add timing::DIRECTIVES to the global EnvFilter, or under RUST_LOG=warn no action is timed.

4. Watching#

Each run, and each list.changed(topic, detail) the app reports (“project opened”, “track renamed”), is an ActionEvent with a sequence number. events_since(seq, max, wait) returns what came after seq, waiting up to wait for the next event when none has arrived yet. Long-polls and the MCP watch tool are built on it. The list keeps the last EVENT_HISTORY (4096) events.

Async actions#

An action that awaits (a service call, a query, a peer) registers with register_async. Its arguments are decoded when it is called, before the handler runs, so a bad call is refused without running anything:

list.register_async(&ASK, Effect::ReadOnly, move |q: Ask| {
    let client = client.clone();
    async move { client.ask(q.name).await.map_err(|e| e.to_string()) }
})?;

let out = list.run_json_async("PEER_ASK", br#"{"name":"ada"}"#).await?;
  • run_json_async / run_json_async_as run every action, sync or async, so a caller that can await (the MCP server, a script) uses them for everything.
  • The sync run_json refuses an async action (Failed, “run it with run_json_async”) rather than block the thread it was called on. A key or menu handler spawns run_json_async on the app’s runtime.
  • A panic inside the future is caught and answered as Failed, as for a sync handler. The enabled check and the authorizer run before the future exists.
  • probe_hostile_args only decodes an async action: its future is dropped unpolled, so the probe never does what the action does.
  • register_dynamic_async takes an AsyncHandler already erased to JSON in, a future of JSON out.

5. MCP: a model on the same list#

architect-mcp serves an ActionList as a Model Context Protocol server. It offers five generic tools, not one per action (an app has hundreds):

ToolWhat it does
actionsthe list, filtered by text, category or effect
describeone action: what it does, takes and gives (JSON Schema)
runrun one action with its arguments
batchrun several in order, stopping at the first failure (at most MAX_BATCH_STEPS, 64; not atomic)
watchruns and state changes since a point, long-polling
let server = architect_mcp::Server::new("session", env!("CARGO_PKG_VERSION"), list.clone())
    .with_instructions("Session's commands. Call `actions` first.");

// stdio, for a model that launches the app:
architect_mcp::serve_stdio(&server)?;

// or live, on loopback (feature `http`): POST /mcp, bearer token
let listener = tokio::net::TcpListener::bind("127.0.0.1:47811").await?;
tokio::spawn(architect_mcp::http::serve(listener, server, Some(token)));
  • Server::handle(message) is the whole protocol (one JSON-RPC message in, the reply out), so other transports are a thin loop around it.
  • Every call runs as a Principal: with_principal(p) for the server (Anonymous unless set), or handle_as(p, message) per request. With an authorizer on the list, actions, describe and actions://list show that principal only what it may run, and run and each batch step are authorized on their own.
  • A batch is not atomic: steps that ran stay done when a later one fails. Over MAX_BATCH_STEPS steps it is refused whole.
  • actions and describe carry enabled and disabled_reason.
  • The HTTP face refuses any browser Origin that isn’t localhost (the MCP DNS-rebinding guard) and requires the bearer token when one is given. Bind to loopback, make it opt-in, and generate the token.
  • Tool results are capped at MAX_TEXT. An action that can answer with a lot should page.
  • handle_with(&caller, message) runs the request for an ActionCaller: its context reaches register_with handlers. A caller with no principal runs as the server’s (with_principal), never as the trusted app.
  • A failed tool answers isError: true with a JSON object ({"code": …, "error": …}, plus takes for bad arguments) by default — the same JSON the control socket answers (docs/content/guide/cli-and-mcp.md). with_errors(ErrorStyle::Text) answers with the plain message instead: a handler’s Err text as it is, bad arguments as bad arguments: … It takes: {schema}.
  • Async actions are awaited. Server::handle_async is the core; Server::handle drives it to the end on the calling thread, which is what serve_stdio and the HTTP transport (on a blocking-pool thread of its runtime) do.

Named tools: keeping an agent’s tool names#

An agent that already calls an app by tool name (create_task, greet) keeps those names when the app moves onto the list. Named mode offers every action as a tool of its own:

let server = architect_mcp::Server::new("greeter", "0", list)
    .with_tools(architect_mcp::Tools::Named)
    .with_tool_name("GREETER_GREET", "greet"); // else `greeter_greet`
  • The tool name is the id in lower case unless with_tool_name gives one. Two actions on one name: the first by id is offered.
  • Its description is the action’s, its inputSchema the action’s params schema (an object; a non-object argument is taken as {"args": …}), and its hints come from the Effect (readOnlyHint, destructiveHint).
  • The principal sees only the tools for actions it may run, as with actions.
  • Tools::Both offers the five generic tools first and the named ones after; an action whose tool name is a generic tool’s is not offered by name. Tools::Generic (the default) is the five alone.

One tool from Rust#

An app that keeps its own MCP endpoint (and merges these tools into a larger catalog) calls the server per tool instead of building JSON-RPC text:

// tools/list entries for this principal: name, description, inputSchema, annotations
let tools: Vec<serde_json::Value> = server.tools_for(&principal);

// tools/call: the result (content + isError) to send back
let caller = ActionCaller::new(principal).with_context(Org(org));
match server.call_tool(&caller, "list_tasks", args).await {
    Ok(result) => result,
    Err(architect_mcp::ToolError::NoTool(_)) => my_other_tools(name, args),
    Err(architect_mcp::ToolError::BadParams(message)) => refuse(message),
}

call_tool is what tools/call runs: the same authorization, named and generic tools, and error style. A tool that ran and failed is Ok with isError: true; Err is only “no such tool” or a generic tool given an argument it does not take.

Schemas a model reads#

params_schema (and so a named tool’s inputSchema) is written from the argument type by architect::schema::json_schema:

  • A documented field is its type’s schema with the field’s doc beside it: {"type": "string", "description": "…"}.
  • An Option<T> field is T’s schema, not in required: a caller leaves it out for none (an explicit null is still read).
  • #[facet(deny_unknown_fields)] on the argument struct makes its decoder refuse unknown fields, and its schema says so with "additionalProperties": false. Without it, unknown fields are ignored and the schema leaves the object open.

6. A service as actions#

#[architect::service(actions)] projects every method of a service into an Action List with one call, so keys, menus, scripts and a model run the same call the vox and HTTP faces serve:

#[architect::service(actions)]
pub trait Greeter {
    /// Greets a known name.
    async fn greet(&self, name: String) -> Result<String, GreetError>;
    /// How many greetings so far.
    #[action(effect = read_only)]
    async fn count(&self) -> Result<u32, GreetError>;
}

greeter_actions::register(backend.clone(), &list)?; // GREETER_GREET, GREETER_COUNT
  • Every method becomes an async action. Its id is <NS>_<METHOD>, where NS is the trait name in SCREAMING_SNAKE minus a trailing Service (actions = "NS" sets it). The doc comment is the description, http::<Method>Args (the HTTP body) is what it takes, and the Ok type is what it gives. The error’s Display is the failure message.
  • #[action(effect = read_only | mutating | destructive, id = "…", name = "…", category = "…", skip)] on a method adjusts it. The effect defaults to mutating, so mark readers read_only: a model trusts it.
  • register goes through the same host the HTTP face uses, so a sync backend’s dispatcher is honoured. IDS lists what it registers.
  • It needs architect’s action-list feature and the HTTP face (so not context = T yet). The effect attribute is refused without actions.
  • examples/greeter/tests/mcp.rs is the reference test: greet listed, described and run over MCP gives what POST /greeter/greet gives, from one backend.

7. Migrating a trait from #[architect::rpc] to #[architect::service]#

#[architect::rpc] is the vox face only. #[architect::service] is the same expansion plus the HTTP face, and with actions the Action List as well. The vox items keep their names, so the switch is a short recipe.

The trait.

  1. Change the attribute to #[architect::service] (or #[architect::service(actions)]). Keep the rpc options (sync_client, ops, scopes(...), path = "…").
  2. Make every Result error an #[architect::error] enum. The HTTP face answers with its HttpError status and code, and the clients fold transport failures into it through From<TransportError>.
  3. Check the arguments make a sensible JSON body. Each method’s arguments become http::<Method>Args { arg: … }. An argument named token may come from Authorization: Bearer instead.
  4. Check the paths: POST /<prefix>/<method-kebab>. The prefix is the kebab trait name minus Service; path = "…" on the trait and #[http(path = "…")] on a method move them.
  5. With actions, mark each method’s effect. Readers get #[action(effect = read_only)], deletes get destructive, and anything a person must never trigger by accident gets skip.

The backend. No change for an all-async trait. A sync or mixed trait is served over HTTP through the dispatcher bridge, so the backend needs HasDispatcher (#[derive(architect::HasDispatcher)]).

The crate’s features. http (architect’s http) compiles the router and http-client compiles <Trait>HttpClient. With actions, the crate also needs architect’s action-list. Mount the router with backend.into_http_router() beside into_router() (examples/greeter/src/main.rs).

The clients. <Trait>Client (vox) is unchanged. For an all-async trait whose methods all return Result, both <Trait>Client and <Trait>HttpClient now implement the trait. Delete any hand-written impl Trait for TraitClient: it would conflict. Then code that takes impl Trait can be handed either client, or the backend itself. The prelude adds <snake>_http (and <snake>_actions with actions).

The tests. Write the scenario once as an async fn over impl Trait. Run it in-process, over LocalServer (vox) and over HTTP, as examples/greeter/tests/round_trip.rs does. With actions, add the registry test (register, validate, probe_hostile_args) and one named-MCP check that a method answers as its HTTP route does (examples/greeter/tests/mcp.rs). Delete any hand-written axum route, DTO or MCP tool the generated face now covers, along with its test.

When to stay rpc.

  • The trait has an ambient context = T. Neither the HTTP face nor the projection can pull T out of a request yet.
  • Nothing outside the process tree should reach it: a plugin-to-host or engine-internal trait, or a REAPER main-thread bridge, where an HTTP route would only be surface to guard.
  • Its traffic is live signal or bulk bytes: meters, playheads, audio or files. Those are #[subscribe] streams, PubSub or architect::blob, not JSON request/response.
  • Its arguments can’t be a JSON body a person or a model would write (opaque handles, large binary).

Otherwise prefer service, because the HTTP face and the action projection come from the same declaration.

Serving on loopback and being found#

An app that serves its list live does three things: bind loopback, say where it is, and name it for Claude Code. architect-mcp does all three, so the app does not hand-roll port, token or file handling:

// feature `http`; on the app's tokio runtime
let server = architect_mcp::Server::new("session", env!("CARGO_PKG_VERSION"), list.clone());
// 127.0.0.1, port from FTS_MCP_PORT (else 47811), token from FTS_MCP_TOKEN
// when non-empty; Ok(None) when FTS_MCP=0.
if let Some(serving) = server
    .serve_loopback_or(47_811, "FTS_MCP_PORT", "FTS_MCP_TOKEN")
    .await?
{
    // ~/.config/fts/session-mcp.json: {"url": ..., "token": ...}, mode 0600
    serving.publish("session")?;
}
// a dev tool or build step, not every launch: the repo's .mcp.json
architect_mcp::project_mcp_json(repo_root, "session", "http://127.0.0.1:47811/mcp")?;
  • serve_loopback(port_env, token_env) takes any free port when the variable is unset; serve_loopback_or gives a fixed default, which a committed .mcp.json needs. A port variable that is not a port is an error, as is a port already taken.
  • The discovery file <config>/fts/<name>-mcp.json is how any client finds the port and the token (architect_mcp::read(name)). It is written to a 0600 temporary file and renamed into place, so the token is never readable by another user. Call unpublish(name) when the app stops serving.
  • .mcp.json carries the URL only, never the token: it is committed. project_mcp_json keeps the other servers in the file and writes nothing when the entry already matches.
  • The token stays optional. Without one, anything on this machine can run every action; set a token for an app with destructive actions.

Serving on an engine host#

A server on the network mounts MCP on its EngineHost instead (feature host, features/mcp/src/host.rs McpHost). .mcp(server, token) is the loopback router above on the host. .mcp_at(path, resolve) serves a path (/org/{slug}/mcp, an account-wide /mcp) and asks resolve, per request, which server to answer from and who the request runs as:

use architect_mcp::host::{McpHost, McpTarget};

host.mcp_at("/org/{slug}/mcp", move |head: RequestHead| {
    let orgs = orgs.clone();
    async move {
        let org = orgs.get(head.param("slug").unwrap_or_default()).ok_or(StatusCode::NOT_FOUND)?;
        let who = org.principal_for(head.bearer()).await.ok_or(StatusCode::UNAUTHORIZED)?;
        Ok(McpTarget::new(org.mcp.clone(), ActionCaller::new(who)))
    }
})

There is no origin check on mcp_at: authentication is the resolver’s, and the caller’s principal goes through the list’s authorizer as for handle_as. examples/greeter/tests/host_mounts.rs drives both.

Rules#

  1. One command per capability. If a person or an agent can do it, it is an action in the app’s list. Don’t add a bespoke MCP tool, menu handler or palette entry beside it.
  2. A programmatic run never opens a dialog. UI-only effects queue a window for the UI to open (Session’s studio:apps/session/daw/src/actions.rs is the reference).
  3. Pick the effect honestly. A model decides what it may run without asking from Effect.

Known gaps#

These are tracked in the Architect project. Until they land:

  • run_json (keys, menus) refuses async actions: spawn run_json_async instead.
  • A service’s action failure is its error’s Display, not the HTTP face’s structured {code, message}.
  • The router’s PermissionsGate and the list’s authorizer are separate planes: give the list the same engine (set_authorizer) the router’s gate uses. Don’t expose the list beyond loopback without a token.
  • The MCP HTTP face has one principal per server; a transport that resolves a caller per request calls Server::handle_as / Server::handle_with.
  • #[architect::service(actions)] handlers do not see the caller: the projection registers through register_async. A per-org service is registered by hand with register_async_with.

Linked from

architect·Published with architect