Add an Agent
protoWorkstacean supports two agent patterns:
- In-process — the agent runs inside the workstacean process, powered by
@protolabsai/sdk. Skills execute as @protolabsai/sdk sessions with a configurable system prompt and tool whitelist. Example today:ava(conversational chat agent, no tools). - External A2A — the agent runs in a separate service with its own HTTP surface. protoWorkstacean calls it over JSON-RPC 2.0. Right choice for stateful agents with their own infrastructure. Examples today: the protoMaker team (at
${AVA_BASE_URL}/a2a, handles board ops and planning), Quinn (PR review, bug triage), protoContent (Jon/Cindi content), Frank (infra).
Both patterns register into ExecutorRegistry and are dispatched by SkillDispatcherPlugin. From the bus’s perspective they are identical — both consume agent.skill.request and reply on agent.skill.response.<correlationId>.
Path A: In-process agent
Section titled “Path A: In-process agent”In-process agents are defined in workspace/agents/<name>.yaml. AgentRuntimePlugin reads all .yaml files in that directory at startup and registers a ProtoSdkExecutor for each one.
YAML schema
Section titled “YAML schema”# Unique agent name — used for routing and logging.name: my-agent
# Role affects how the agent is described in logs and the /api/agents endpoint.# Options: orchestrator | qa | devops | content | research | generalrole: general
# LLM model alias recognised by your gateway.model: claude-sonnet-4-6
# Full system prompt injected on every turn.systemPrompt: | You are My Agent, a specialist in...
# Workstacean bus tools this agent may call.# Available tools: publish_event, get_world_state, get_incidents, report_incident,# get_ceremonies, run_ceremonytools: - get_world_state - publish_event
# Agent names this agent may delegate work to (at most 2 levels deep).# Must match names of other agent definitions.canDelegate: []
# Max agentic turns per skill invocation. -1 = unlimited.maxTurns: 15
# Skills this agent handles.# name must match the skillHint arriving on agent.skill.request.# keywords are matched case-insensitively against message content for auto-routing.skills: - name: my_skill description: "What this skill does" keywords: [keyword1, keyword2, /my-command]
- name: another_skill description: "Another capability" # No keywords — dispatched programmatically only (e.g. from ceremonies or actions)How routing works
Section titled “How routing works”When a agent.skill.request message arrives, SkillDispatcherPlugin calls ExecutorRegistry.resolve(skill, targets):
- If
targetsis non-empty (explicit agent routing), the first target whoseagentNamematches anAgentRuntimePluginregistration wins. - Otherwise, it looks for a registration whose
skillmatches — which is set by theskills[].nameentries in the YAML. - If nothing matches, the default executor (if any) handles it.
RouterPlugin sets the skill field based on:
payload.skillHint— set explicitly by surface plugins (Discord slash commands, cron events)- Keyword matching against the message content using
skills[].keywords ROUTER_DEFAULT_SKILLenvironment variable — catch-all fallback
Registering the executor
Section titled “Registering the executor”AgentRuntimePlugin calls executorRegistry.register(skill.name, executor, { agentName: agent.name }) for each skill in the YAML. No restart is required if you add a new agent file — restart is required currently; hot-reload is not implemented for agent definitions.
Minimal example
Section titled “Minimal example”name: helperrole: generalmodel: claude-haiku-4-5-20251001systemPrompt: | You are Helper. Answer questions concisely.tools: []maxTurns: 5skills: - name: answer description: Answer a question keywords: [help, question, ?]Test it:
curl -X POST http://localhost:3000/publish \ -H "X-API-Key: $WORKSTACEAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "topic": "agent.skill.request", "payload": { "skill": "answer", "content": "What time is it?", "correlationId": "test-001", "replyTopic": "agent.skill.response.test-001" } }'Path B: External A2A agent
Section titled “Path B: External A2A agent”External agents are defined in workspace/agents.yaml. SkillBrokerPlugin reads this file at startup and registers an A2AExecutor for each skill declared.
YAML schema
Section titled “YAML schema”agents: - name: my-service # Full URL of the agent's /a2a endpoint (JSON-RPC 2.0). url: http://my-service:8080/a2a # Auth — either the legacy apiKeyEnv shorthand OR a structured auth block. apiKeyEnv: MY_SERVICE_API_KEY # legacy: X-API-Key: <env> # auth: # preferred (Phase 8): # scheme: bearer # "apiKey" | "bearer" | "hmac" # credentialsEnv: MY_SERVICE_TOKEN # Optional: stamp static headers (e.g. opt in to A2A extensions). # headers: # a2a-extensions: "https://a2a-protocol.org/ext/cost-v1" # Whether the agent supports SSE streaming (card-derived fallback). streaming: false # Skills this agent handles. Omit to auto-discover from the agent card. skills: - name: analyze_data description: Analyze a dataset and return a summary - name: generate_report description: Generate a formatted report # Bus topics this agent subscribes to directly (informational — not enforced by workstacean). subscribesTo: - message.inbound.#Auth resolution:
apiKeyEnv: X→ sendsX-API-Key: $Xon every request (legacy shorthand).auth.scheme: apiKey+credentialsEnv: X→ same header, explicit scheme.auth.scheme: bearer+credentialsEnv: X→ sendsAuthorization: Bearer $X.auth.scheme: hmac→ reserved for future HMAC-signing extension.
At request time, A2AExecutor reads process.env[credentialsEnv] (or apiKeyEnv as fallback) and stamps the right header based on scheme.
How the A2A call is made
Section titled “How the A2A call is made”A2AExecutor sends a message/send JSON-RPC 2.0 request:
{ "jsonrpc": "2.0", "id": "<uuid>", "method": "message/send", "params": { "message": { "role": "user", "parts": [{ "kind": "text", "text": "<skill content>" }] }, "contextId": "<correlationId>", "metadata": { "skillHint": "<skill>", "correlationId": "<correlationId>", "parentId": "<parentId>" } }}Distributed trace headers are always included:
X-Correlation-Id: <correlationId>X-Parent-Id: <parentId> (if present)X-API-Key: <resolved key>The receiving service should propagate contextId / X-Correlation-Id through its own spans.
Skills refreshed from the agent card
Section titled “Skills refreshed from the agent card”You can omit skills from agents.yaml if your service exposes a /.well-known/agent-card.json (or legacy /.well-known/agent.json) discovery endpoint. SkillBrokerPlugin fetches it at startup and registers declared skills automatically, then re-fetches every 10 min so new skills land without a restart. When both yaml skills and card skills are present, the yaml entries take precedence as explicit overrides.
Long-running tasks
Section titled “Long-running tasks”If your agent returns a non-terminal Task (state: submitted or working) instead of an immediate reply, SkillDispatcherPlugin hands the task to TaskTracker which polls tasks/get every 30s (or uses tasks/resubscribe for streaming agents). When the task reaches a terminal state, the tracker publishes the response on the original reply topic — the caller sees exactly one response, just later.
For agents that support push notifications (capabilities.pushNotifications: true in the card), workstacean registers PushNotificationConfig with a per-task HMAC token pointing at ${WORKSTACEAN_BASE_URL}/api/a2a/callback/:taskId. The agent POSTs Task snapshots to that URL when the state changes, which is faster and cheaper than polling.
input-required → HITL
Section titled “input-required → HITL”When your agent returns Task.status.state == "input-required", the tracker automatically raises a HITL request (Discord approval UI by default). Once a human responds, the tracker resumes the task with message/send on the same taskId carrying the decision text. No custom plan_resume skill is needed — this is the native A2A state machine.
Workstacean as an A2A server
Section titled “Workstacean as an A2A server”Workstacean itself is an A2A agent too. It exposes:
GET /.well-known/agent-card.json— lists every skill registered inExecutorRegistryPOST /a2a— JSON-RPC 2.0 endpoint (supportsmessage/send,message/stream,tasks/*)
External agents can call workstacean by resolving the card and dispatching skills with a skillHint in the message metadata. Auth is the same WORKSTACEAN_API_KEY via Authorization: Bearer <key> or X-API-Key. See HTTP API reference — POST /a2a for full details.
Checking registrations
Section titled “Checking registrations”List all registered executors at runtime:
curl http://localhost:3000/api/agentsReturns:
[ { "name": "ava", "type": "proto-sdk", "skills": ["chat"] }, { "name": "protomaker", "type": "a2a", "skills": ["sitrep", "board_health", "manage_feature", "bug_triage"] }, { "name": "quinn", "type": "a2a", "skills": ["pr_review", "bug_triage", "security_triage"] }]