Skip to content

Integrate an External App

Connect any external service to the GOAP world engine as a reactive actor. The engine polls the app’s health endpoints, evaluates goals against the data, and dispatches actions (alerts or A2A skill calls) when something is wrong.

Zero code changes per integration. Everything is declarative YAML + environment variables.

An external app integration has four layers:

LayerFilePurpose
Observeworkspace/domains.yamlPoll the app’s HTTP endpoints for state
Evaluateworkspace/goals.yamlDefine what “healthy” looks like
Actworkspace/actions.yamlDefine what to do when unhealthy
Dispatchworkspace/agents.yamlRegister the app’s A2A skills for action execution

The GOAP loop connects them automatically:

domains.yaml (poll) → goals.yaml (evaluate) → actions.yaml (match) → agents.yaml (dispatch)
↑ ↓
└────────────── next tick picks up changes ──────────────────────────────┘

The external app must expose:

  1. At least one HTTP GET endpoint returning JSON health/state data
  2. (Optional) An A2A endpoint (POST /a2a) implementing JSON-RPC 2.0 message/send for skill dispatch

If the app only has health endpoints and no A2A interface, you can still use it — actions will be limited to alerts and ceremony triggers instead of direct skill dispatch.

Create or append to workspace/domains.yaml:

domains:
- name: myapp_health
url: "${MYAPP_BASE_URL}/api/health"
tickMs: 60000
headers:
X-API-Key: "${MYAPP_API_KEY}"
  • name becomes domains.myapp_health in world state
  • URL and header values support ${ENV_VAR} interpolation
  • tickMs defaults to 60000 (1 minute) if omitted
  • The engine automatically sets extensions.myapp_health_available to true/false on each tick

Set the environment variables:

Terminal window
MYAPP_BASE_URL=http://myapp:8080
MYAPP_API_KEY=your-api-key

See Add a domain for the full schema reference.

Append to workspace/goals.yaml:

- id: myapp.service_healthy
type: Invariant
severity: high
selector: "domains.myapp_health.data.status"
operator: eq
value: "ok"
description: "MyApp service must report healthy status"

The selector path follows the pattern domains.<name>.data.<field>. The engine unwraps { success, data } API envelopes automatically, so data refers to the inner payload.

Common goal types:

TypeUse caseExample
InvariantBoolean/enum checksstatus == "ok", connected == true
ThresholdNumeric boundserrorRate < 0.05, agentCount >= 1
DistributionPercentage mixfeatureRatio >= 0.4

See Add goals and actions for all operators and types.

Append to workspace/actions.yaml. Every action must guard on domain availability:

# Tier 0: alert (free, fire-and-forget)
- id: alert.myapp_unhealthy
goalId: myapp.service_healthy
tier: tier_0
priority: 10
cost: 0
name: "Alert when MyApp is unhealthy"
preconditions:
- path: "extensions.myapp_health_available"
operator: eq
value: true
- path: "domains.myapp_health.data.status"
operator: neq
value: "ok"
effects: []
meta:
topic: "message.outbound.discord.alert"
fireAndForget: true
# Tier 0: dispatch to agent (if A2A is available)
- id: action.myapp_self_heal
goalId: myapp.service_healthy
tier: tier_0
priority: 20
cost: 1
name: "Dispatch MyApp to self-heal"
preconditions:
- path: "extensions.myapp_health_available"
operator: eq
value: true
- path: "domains.myapp_health.data.status"
operator: neq
value: "ok"
effects: []
meta:
skillHint: diagnose
agentId: myapp
fireAndForget: true

Key fields:

  • preconditions[0]always guard on availability to avoid firing on stale data
  • meta.skillHint — which skill to invoke on the external agent
  • meta.agentId — which agent to route to (matches name in agents.yaml)
  • meta.fireAndForget: true — complete immediately (use false to wait for outcome)

If the app has an A2A endpoint, create or append to workspace/agents.yaml:

agents:
- name: myapp
url: "${MYAPP_BASE_URL}/a2a"
apiKeyEnv: MYAPP_API_KEY
skills:
- name: diagnose
description: Run diagnostics and attempt self-healing
- name: status
description: Generate a status report
  • url supports ${ENV_VAR} interpolation
  • apiKeyEnv is the name of the env var (not the value)
  • Skills are registered with the ExecutorRegistry and become routable via agent.skill.request

See Add an agent for the full A2A schema.

Add the environment variables to your deployment config (docker-compose, Infisical, etc.):

environment:
- MYAPP_BASE_URL=${MYAPP_BASE_URL:-}
- MYAPP_API_KEY=${MYAPP_API_KEY:-}

Restart workstacean. Check the logs for:

[domain-discovery] global: registered 1 domain(s)
[skill-broker] Registered 1 A2A agent(s)
[world-state-engine] Domain "myapp_health" registered (tick: 60000ms)
Terminal window
# Check domain is collecting
curl http://localhost:3000/api/world-state/myapp_health
# Check availability flag
curl http://localhost:3000/api/world-state | jq '.data.extensions.myapp_health_available'
# Check action outcomes (after a goal violation fires)
curl http://localhost:3000/api/outcomes | jq '.recent[] | select(.actionId | startswith("myapp"))'

The protoMaker team integration (the multi-agent board runtime reached via AVA_BASE_URL) is the canonical example. It demonstrates:

  • Two domains: protomaker_board (blocked features) and protomaker_pipeline (auto-mode, running agents)
  • Five A2A skills: sitrep, board_health, auto_mode, manage_feature, bug_triage
  • Two goals: board health (max 3 blocked) and auto-mode active
  • Three actions: alert on blocked, dispatch the protoMaker team to triage, alert auto-mode off

Files:

  • workspace/domains.yaml — domain definitions
  • workspace/agents.yaml — A2A agent registration
  • workspace/goals.yaml — Ava goals section
  • workspace/actions.yaml — Ava actions section
WorldStateEngine polls domain every tickMs
GoalEvaluator detects violation
L0 Planner matches action preconditions against world state
ActionDispatcher publishes to agent.skill.request
SkillDispatcher extracts skillHint + agentId
ExecutorRegistry resolves to A2AExecutor
HTTP POST to app's /a2a endpoint (JSON-RPC 2.0)
App acts, world state updates on next tick
Goal re-evaluates — satisfied → loop quiets

If the action fails 3 times within 5 minutes, the LoopDetector triggers oscillation cooldown (10 minutes) and escalates to tier_1.