This document defines a REST-style HTTP surface for exposing a3s-flow
through an Axum server.
Goals:
- preserve the progressive API layering already present in
FlowEngine - keep transport payloads close to the Rust API names
- make dynamic node type management explicit and safe
- support both editor-style clients and hosted workflow platforms
- align the public route shape with the existing
a3s-boxAPI style
a3s-box exposes its UI/backend API as resource families under /api/box/*,
for example:
/api/box/containers/api/box/images/api/box/networks/api/box/volumes/api/box/info- action routes such as
/api/box/stop/:idand/api/box/pull/:ref
a3s-flow should follow the same transport philosophy:
- use
/api/flow/*, not a separate RPC-like namespace - group routes by resource family, not by abstract capability layer
- use collection routes for snapshots and listings
- use small action routes for lifecycle transitions
- keep route names short and UI-friendly
The progressive aspect should exist in the resource model itself:
- catalog and validation
- execution creation and inspection
- event observation
- runtime control
- shared context mutation
- definition-backed execution
Recommended base path:
/api/flow
Examples below assume that prefix.
All non-2xx responses should use a stable JSON envelope:
{
"error": {
"code": "protected_node_type",
"message": "node type is protected and cannot be removed: noop"
}
}Suggested error code mapping:
| FlowError | HTTP | error.code |
|---|---|---|
InvalidDefinition |
400 |
invalid_definition |
UnknownNode |
400 |
unknown_node |
ExecutionNotFound |
404 |
execution_not_found |
FlowNotFound |
404 |
flow_not_found |
InvalidTransition |
409 |
invalid_transition |
ProtectedNodeType |
409 |
protected_node_type |
Internal |
500 |
internal |
Terminated |
409 |
terminated |
Execution state:
{
"execution_id": "e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905",
"state": "running"
}Terminal execution state:
{
"execution_id": "e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905",
"state": "completed",
"result": {
"execution_id": "e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905",
"outputs": {
"end": {
"answer": "ok"
}
}
}
}Validation response:
{
"valid": false,
"issues": [
{
"node_id": "b",
"message": "unknown node type 'does-not-exist'"
}
]
}To stay consistent with a3s-box, the HTTP surface should be organized into
these top-level families:
| Family | Purpose |
|---|---|
/api/flow/info |
engine-level metadata and summary |
/api/flow/nodes |
node catalog and runtime node type management |
/api/flow/validate |
pre-flight validation |
/api/flow/executions |
execution creation and snapshot lookup |
/api/flow/events |
event streaming |
/api/flow/context |
shared mutable execution context |
/api/flow/definitions |
named/stored flow entry points |
This gives a3s-flow the same “tabbed resource” feel as a3s-box.
Returns engine-level summary information plus the capabilities document.
Response 200:
{
"engine": "a3s-flow",
"version": "2026-03-22",
"progressive_disclosure": true,
"summary": "A3S Flow exposes a discoverable catalog of workflow node capabilities.",
"node_count": 16,
"capabilities": {
"version": "2026-03-22",
"progressive_disclosure": true,
"summary": "A3S Flow exposes a discoverable catalog of workflow node capabilities.",
"nodes": []
}
}Returns engine.capabilities().
Response 200:
{
"version": "2026-03-22",
"progressive_disclosure": true,
"summary": "A3S Flow exposes a discoverable catalog of workflow node capabilities.",
"nodes": []
}Returns a light-weight list of node type strings.
Response 200:
{
"node_types": ["assign", "http-request", "llm", "noop"]
}Returns engine.node_descriptors().
Response 200:
{
"nodes": [
{
"node_type": "http-request",
"display_name": "HTTP Request",
"category": "integration",
"summary": "Calls external HTTP APIs with configurable method, headers, and body.",
"default_data": {
"method": "GET",
"url": "",
"headers": {}
},
"fields": [
{
"key": "method",
"kind": "string",
"required": true,
"description": "HTTP method."
}
]
}
]
}These endpoints control the engine's runtime node registry. They only affect future validations and executions.
Built-in node types are protected from deletion.
Registers or replaces a custom node type.
Because a dyn Node cannot be transported over HTTP directly, this endpoint is
only suitable when the server supports a fixed set of server-side factories or
plugins. The request identifies which server-known implementation to bind.
Request:
{
"factory": "slow-test-node",
"descriptor": {
"display_name": "Slow Node",
"category": "testing",
"summary": "Sleeps briefly during tests.",
"default_data": {
"delay_ms": 10
},
"fields": [
{
"key": "delay_ms",
"kind": "number",
"required": false,
"description": "Sleep duration in milliseconds."
}
]
}
}Response 201:
{
"node_type": "slow",
"registered": true,
"replaced": false
}Notes:
factoryis a transport-facing identifier resolved by the server- the server decides the actual Rust
Nodeimplementation and finalnode_type - if
descriptoris omitted, the server may use the node's built-in metadata
Deletes a runtime-registered node type.
Response 200:
{
"node_type": "slow",
"removed": true
}Response 409 when attempting to delete a built-in type:
{
"error": {
"code": "protected_node_type",
"message": "node type is protected and cannot be removed: noop"
}
}Response 404 when the type does not exist:
{
"error": {
"code": "node_type_not_found",
"message": "node type not found: slow"
}
}Recommendation:
- prefer
404instead of200 { removed: false }for HTTP clients - keep the Rust API permissive, but make the HTTP contract explicit
This follows the same singular-resource delete style that a3s-box uses with
routes like /api/box/container/:id and /api/box/image/:ref.
Request:
{
"definition": {
"nodes": [
{ "id": "a", "type": "noop" }
],
"edges": []
}
}Response 200:
{
"valid": true,
"issues": []
}Starts an inline flow definition.
Request:
{
"definition": {
"nodes": [
{ "id": "a", "type": "noop" }
],
"edges": []
},
"variables": {
"user_id": "u_123"
}
}Response 202:
{
"execution_id": "e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905",
"state": "running"
}Returns the latest execution snapshot.
Response 200:
{
"execution_id": "e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905",
"state": "paused"
}For box-style consistency, the collection route is plural and the single-item snapshot route is singular.
Streams FlowEvent values.
Recommended transport:
- Server-Sent Events for browser and CLI clients
- WebSocket optional if bidirectional interaction is needed later
SSE event examples:
event: flow.started
data: {"execution_id":"e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905"}
event: node.completed
data: {"execution_id":"e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905","node_id":"a","output":{}}
event: flow.completed
data: {"execution_id":"e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905","result":{"outputs":{"a":{}}}}
This keeps events as their own resource family instead of nesting them under
execution resources. That matches the flat, UI-oriented style used by the
existing a3s-box API.
Response 200:
{
"execution_id": "e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905",
"state": "paused"
}Response 200:
{
"execution_id": "e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905",
"state": "running"
}Response 202:
{
"execution_id": "e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905",
"state": "terminating"
}Action routes are intentionally short and command-like here because that is how
a3s-box exposes lifecycle operations such as /api/box/stop/:id.
Response 200:
{
"context": {
"approval": "granted"
}
}Request:
{
"value": "granted"
}Response 200:
{
"key": "approval",
"updated": true
}Response 200:
{
"key": "approval",
"removed": true
}Request:
{
"variables": {
"topic": "ai infra"
}
}Response 202:
{
"execution_id": "e6b7e7cf-3534-4d1b-b0d1-4e2c2788f905",
"state": "running",
"flow_name": "daily-briefing"
}This mirrors a3s-box action naming more closely than
/definitions/:name/executions.
Suggested internal handler-to-engine mapping:
| HTTP handler | FlowEngine call |
|---|---|
GET /info |
capabilities() + local summary |
GET /capabilities |
capabilities() |
GET /node-types |
node_types() |
GET /nodes |
node_descriptors() |
POST /node-types |
register_node_type[_with_descriptor]() via server plugin registry |
DELETE /node-type/:node_type |
unregister_node_type() |
POST /validate |
validate() |
POST /executions |
start() |
GET /execution/:id |
state() |
GET /events/:id |
start_streaming() or server-side subscription bridge |
POST /pause/:id |
pause() |
POST /resume/:id |
resume() |
POST /terminate/:id |
terminate() |
GET /context/:id |
get_context() |
PUT /context/:id/:key |
set_context_entry() |
DELETE /context/:id/:key |
delete_context_entry() |
POST /run/:name |
start_named() |
- Runtime registration over HTTP requires a server-side plugin/factory registry because arbitrary Rust
Nodetrait objects cannot be uploaded directly as JSON. - Event streaming for an existing execution may require the server to keep a live subscription registry if clients attach after
start(). - If named-flow storage becomes mutable over HTTP later, that should likely be a separate resource family:
/v1/flow/definitions
- Authentication and multi-tenant isolation should be applied at the router layer, not embedded into flow payloads.