Plane
Plane is the project management layer for protoLabs. It acts as the human-facing strategic interface: ideas become Plane issues, Plane issues become SPARC PRDs, and approved PRDs become board features in the protoLabs Studio backlog. Workstacean is the bridge.
Overview
Section titled “Overview”Configure Plane with your workspace slug and workspace ID (found in your Plane workspace settings).
The flow in one sentence: a Plane issue labelled plan or auto fires a webhook → Workstacean’s PlanePlugin validates and deduplicates the event → publishes to the internal bus → Ava runs SPARC PRD + antagonistic review → HITL approval gate (skipped for auto) → features created on the board → Plane issue state and a summary comment are synced back.
Webhook creation — use Django ORM, not the UI or API
Section titled “Webhook creation — use Django ORM, not the UI or API”The Plane API key (/api/v1/ paths) works fine for reading and writing workspace data. The webhook management endpoint lives at /api/workspaces/{slug}/webhooks/, which is session-authenticated only. Trying to create a webhook via API key returns 401 even though the response body looks like a generic DRF 401 (not a helpful “session required” message).
The only reliable way to create or update the webhook is directly via Django ORM in the Plane container:
docker exec -it plane-api python manage.py shellfrom plane.db.models import Webhook, Workspacews = Workspace.objects.get(slug="protolabsai")wh = Webhook.objects.create( workspace=ws, url="http://workstacean:8083/webhooks/plane", is_active=True, issue=True, # fires on create/update/delete cycle=False, module=False, project=False,)print(wh.secret_key) # copy this — store as PLANE_WEBHOOK_SECRETThe secret_key printed by the ORM is the HMAC secret. Store it immediately — you cannot retrieve it again after the shell session.
API path gotcha
Section titled “API path gotcha”| Path prefix | Auth method | Works for |
|---|---|---|
/api/v1/workspaces/... | API key (X-Api-Key header) | Issues, states, projects, members |
/api/workspaces/... | Session cookie only | Webhooks, some admin paths |
If you hit a 401 on /api/workspaces/... with a valid API key, this is a Django REST Framework routing bug — the path is not registered under the API-key-authenticated router at all. Switch to the Django ORM approach.
Trigger Rules
Section titled “Trigger Rules”The PlanePlugin (lib/plugins/plane.ts) filters inbound webhook events by label:
| Label | Behaviour |
|---|---|
plan | Routes to Ava via bus, requires HITL approval before features are created |
auto | Routes to Ava via bus, skips HITL gate entirely — features created immediately |
| (anything else) | Silently dropped |
Only issue events (create/update/delete) are subscribed. Project/cycle/module events are ignored.
Plane → Workstacean → Ava Flow
Section titled “Plane → Workstacean → Ava Flow”1. Plane issue created/updated with "plan" or "auto" label2. POST /webhooks/plane (port 8083 on workstacean container)3. PlanePlugin: a. Verify HMAC-SHA256 signature against PLANE_WEBHOOK_SECRET → 401 if invalid b. Check X-Plane-Delivery UUID against 10k-entry deduplication ring → silent drop if already seen c. Check issue labels for "plan" or "auto" → silent drop if neither label present d. Extract planeIssueId and planeProjectId e. Publish to bus: topic: message.inbound.plane.issue.create skillHint: "plan" correlationId: plane-{issueId} f. Store {planeIssueId, planeProjectId} in pendingIssues Map keyed by correlationId4. RouterPlugin routes to Ava (skillHint "plan" → plan skill)5. Ava runs SPARC PRD + antagonistic review (Ava operational lens + Jon strategic lens)6. HITL gate (native A2A): - "auto" label → Ava short-circuits and returns a completed Task directly - "plan" label → Ava returns Task with state "input-required" → TaskTracker raises HITLRequest on plane.reply.{correlationId}7. On approval: TaskTracker sends message/send with the same taskId, Ava resumes in-context and creates board features (same correlationId throughout)8. PlanePlugin outbound handler picks up plane.reply.# events → syncs back to PlaneBidirectional Sync
Section titled “Bidirectional Sync”After a plan is approved and features are created, the PlanePlugin outbound handler subscribes to plane.reply.# and performs two API calls against PLANE_BASE_URL (http://ava:3002):
- PATCH issue state: sets the issue to “In Progress” when a plan is approved; sets it to “Done” when the plan completes (all features created).
- POST comment: posts a summary comment to the issue with a brief description of what was planned and which features were created.
The pendingIssues Map
Section titled “The pendingIssues Map”A2A replies don’t carry Plane metadata — they only carry correlationId. When the PlanePlugin first publishes an event it stores {planeIssueId, planeProjectId} in a pendingIssues Map keyed by correlationId (format: plane-{issueId}). The outbound handler looks up this map to reconstruct the API call targets. The map is in-memory and not persisted; a workstacean restart clears it, which means in-flight approvals would lose their sync-back path (the board features still get created; only the Plane state update is lost).
Secrets
Section titled “Secrets”All secrets live in Infisical. Two projects hold Plane-related secrets:
| Secret | Infisical Project | Notes |
|---|---|---|
PLANE_WEBHOOK_SECRET | AI project (11e172e0) and homelab project | HMAC key from Django ORM creation step |
PLANE_API_KEY | AI project (11e172e0) | For /api/v1/ reads and writes |
Workstacean env vars (set via infisical run):
PLANE_WEBHOOK_SECRET — HMAC validation for incoming webhooksPLANE_API_KEY — outbound API calls to PlanePLANE_BASE_URL — defaults to http://ava:3002PLANE_WORKSPACE_SLUG — defaults to protolabsaiThese are declared in stacks/ai/docker-compose.yml in the homelab-iac repo under the workstacean service.
Bus Topics
Section titled “Bus Topics”| Topic | Direction | Description |
|---|---|---|
message.inbound.plane.issue.create | Published (inbound) | New issue event with skillHint: "plan" |
message.inbound.plane.issue.update | Published (inbound) | Update event (currently not routed) |
plane.reply.{issueId} | Subscribed (outbound) | A2A reply — triggers state PATCH + comment |
BusMessage Shape
Section titled “BusMessage Shape”The inbound message published to message.inbound.plane.issue.create:
{ correlationId: "plane-{issueId}", source: { interface: "plane", channelId: projectId, userId: actorId }, reply: { topic: "plane.reply.{issueId}", format: "structured" }, payload: { planeIssueId, planeProjectId, planeWorkspaceId, planeSequenceId, title, // issue name description, // stripped description content, // "Plan: {name}\n\n{description}" — sent to Ava priority, // "urgent" | "high" | "medium" | "low" | "none" labels, // raw UUID array autoApprove, // true if "auto" label present skillHint: "plan", }}onboard_project Auto-Provisioning
Section titled “onboard_project Auto-Provisioning”The onboard_project skill (Step 9 of the onboarding chain, triggered on Ava) auto-creates a Plane project for newly onboarded repos:
- Calls
POST /api/v1/workspaces/protolabsai/projects/with the project name and identifier derived from the repo name. - Stores the returned
plane_project_idin two places:.proto/settings.jsoninside the target repoworkspace/projects.yaml(the authoritative project registry in this repo)
This means every onboarded project gets a matching Plane project without manual setup.
MCP Server
Section titled “MCP Server”plane-mcp-server is configured in Ava’s .mcp.json. All agents running on the Ava host inherit 55+ Plane MCP tools covering issues, cycles, modules, members, states, and projects. This allows agents to read and write Plane data directly as tool calls without going through the webhook flow.
Security
Section titled “Security”- Signature:
X-Plane-Signature: sha256=<hex>— HMAC-SHA256 of the raw body. - Deduplication:
X-Plane-Deliveryring buffer (10,000 entries) prevents replay. - Async response: Plane receives
200 OKimmediately; processing runs async to avoid webhook timeouts.
Known Gotchas
Section titled “Known Gotchas”API path quirk: /api/v1/workspaces/... uses API-key auth; /api/workspaces/... is session-only. Both return 401 for unknown paths as well, which makes debugging confusing — a 401 does not always mean wrong credentials, it can mean the path is not registered. Always confirm the path prefix before concluding the API key is invalid.
drop_params: true in LiteLLM: LangChain’s ChatOpenAI sends top_p: -1 when routing through a Claude fallback. This is an invalid value that Claude rejects. drop_params: true is set in general_settings in the LiteLLM config, which strips unknown/invalid parameters before forwarding to the model backend. Without this, Plane-triggered plan requests that hit the Claude fallback fail immediately.
pendingIssues Map is ephemeral: A workstacean restart during an active HITL approval cycle loses the sync-back context. The PRD and features are safe (PlanStore is SQLite-backed); only the Plane issue state update is affected.
Webhook fires on updates too: The webhook is subscribed to all issue events including updates. If someone edits an already-processed issue and the plan label is still present, it will fire again. The 10k-entry deduplication ring by X-Plane-Delivery UUID prevents duplicate processing per delivery, but a new edit generates a new delivery UUID. The plan skill itself is idempotent at the PRD level (correlationId is stable), but be aware that editing an issue title after it has been processed will re-trigger the full flow.
Testing
Section titled “Testing”Layer 1 — Webhook signature verification
Section titled “Layer 1 — Webhook signature verification”PAYLOAD='{"action":"created","issue":{"id":"test-123","labels":[{"name":"plan"}]}}'SECRET=$(infisical secrets get PLANE_WEBHOOK_SECRET --domain https://secrets.proto-labs.ai/api --env=prod --plain)SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)curl -s -o /dev/null -w "%{http_code}" \ -H "Content-Type: application/json" \ -H "X-Plane-Signature: $SIG" \ -H "X-Plane-Delivery: $(uuidgen)" \ -d "$PAYLOAD" \ http://ava:8083/webhooks/plane# Expect 200Layer 2 — Bus injection (bypass webhook, test routing)
Section titled “Layer 2 — Bus injection (bypass webhook, test routing)”curl -s -X POST http://ava:3000/publish \ -H "Content-Type: application/json" \ -d '{ "topic": "message.inbound.plane.issue.create", "payload": { "skillHint": "plan", "correlationId": "plane-test-001", "content": "Build a Discord notification digest for daily standup", "source": { "interface": "plane", "channelId": "test-001" }, "reply": { "topic": "plane.reply.plane-test-001", "format": "text" } } }'Layer 3 — Full end-to-end
Section titled “Layer 3 — Full end-to-end”- Create a Plane issue in workspace
protolabsaiwith a brief description and theplanlabel. - Watch workstacean logs:
docker logs -f workstacean | grep plane - Confirm HMAC validation, dedup check, and bus publish log lines.
- Wait for Ava to complete PRD generation (check Langfuse for the trace).
- An HITL embed should appear in the configured Discord channel.
- Approve via Discord or inject approval (see hitl.md for inject commands).
- Confirm Plane issue state changes to “In Progress” and a comment is posted.