Sales Board
A kanban board built for B2B sales, where every card is a long-lived pursuit — a single sales case that carries its own outreach history, meetings, quotes, and revenue weighting from first prospect touch through to won or lost.
Working with it
Opening a Sales Board launches a kanban board — its dedicated working surface.
How it appears
The same element type rendered as a definition, a circle instance, and a live workspace card.
When to use / not
When to use
- Running a pursuit-based B2B sales pipeline where each deal is a case that lives for weeks or months — not a one-off task — and accumulates notes, LinkedIn touches, meetings, and quotes as it moves through nine stages.
- Turning research candidates into deals: intake prospects into an inbox column, enrich them with a research agent, then bond the responders to permanent Organization + Contact records for consent-aware outreach.
- Reading a stage-weighted revenue forecast or a per-rep 'my work today' queue straight off the pipeline, instead of stitching one together from a spreadsheet.
- Filtering a leads store against an ICP and opening pursuits for every match in one pass.
When not to use
- A generic, non-sales task board (sprints, support triage, content) — use the base `board` element; sales-board's pursuit fields, stages, and outreach ops are dead weight there.
- Hiring and candidate tracking — `recruitment-board` is the kanban specialised for that pipeline.
- Automating outbound email or calls as a flow with wire ports — sales-board is a data element exposing an HTTP card API; orchestrate sends with an `automation` driving the `email` / `phone-number` elements.
Topology
Created from the library and placed inside an app or circle. It is a top-level building block you compose with other elements.
Properties
research_agent_slugstring- Slug of the agent dispatched by research-run. Defaults to 'helm'.
outreach_agent_idstring- UUID of the agent element that drafts outbound messages via the compose-message flow. Resolved by the designated-agent cascade: this board-level setting takes precedence, then the circle's own `outreach_agent_id`, then the first agent element in the circle. Leave unset to use circle defaults.
email_element_slugstring- Slug of the io/email element used for outreach sends.
phone_number_element_slugstring- Slug of the io/phone-number element used for dial-card.
pursuit_modeboolean- When true (default), cards are Pursuits — long-lived sales cases that start bonded to a speculative Lead and convert to bonded Organization + Contact(s) on first real response.
card_actionsarray- Declarative card-level actions — each entry surfaces as a button on the card face (rendered by portal from the `_actions` HATEOAS list on get-card) and is callable via the `run-card-action` op. Same shape + semantics as the base board element's card_actions field; surfaced here so sales-board instances can wire per-card invocations (e.g. "Call customer", "Process deal") without having to use the generic board type. See the base board's properties.yaml for the authoritative shape documentation.
columnsarray- Kanban columns for this sales board. Sales-specific extension: each column carries an optional `stage` field that maps the column to one of the 9 pursuit stages. When a card is created or moved into the column, `metadata.stage` is seeded/synced from this mapping, so the kanban position and the pursuit lifecycle stay in lock-step without the rep having to set stage manually. Default: one column per stage (9 columns), id == stage.key so reps can address them by name (`column_id: "meeting"`) right after board create — no manual add-column calls needed (BUGS-684).
Operations
- activityGET
- add-cardPOST
- add-columnPOST
- add-filePOST
- add-linkedin-touchPOST
- add-note-to-cardPOST
- add-pursuit-notePOST
- add-quotePOST
- add-refPOST
- apply-scaffoldPOST
- approve-linkedin-touchPOST
- archive-cardPOST
- attach-outreach-promptPOST
- attachmentsGET
- batch_statsGET
- board-summaryGET
- composePOST
- compose-message-channelsPOST
- compose-message-draftPOST
- compose-message-optionsPOST
- contextGET
- createPOST
- deleteDELETE
- delete-cardDELETE
- delete-fileDELETE
- detach-outreach-promptPOST
- dial-cardPOST
- disablePOST
- enablePOST
- export_bundleGET
- getGET
- get-cardGET
- get-fileGET
- import_bundlePOST
- ingest-icp-matchesPOST
- intake-prospectPOST
- intentionGET
- list-call-segmentsPOST
- list-callsPOST
- list-cardsGET
- list-entity-activityPOST
- list-filesGET
- list-linkedin-touchesGET
- list-meetingsGET
- list-outreach-promptsPOST
- list-pursuit-notesGET
- list-queuePOST
- list-quotesGET
- log-meetingPOST
- mark-outcomePOST
- mass-intake-prospectsPOST
- move-cardPOST
- move-historyGET
- open-pursuitPOST
- pipeline-forecastGET
- promotePOST
- provisionPOST
- readmeGET
- readme_updatePOST
- remove-columnDELETE
- remove-modifierPOST
- remove-refDELETE
- reorder-cardPOST
- research-prospectPOST
- restorePOST
- schemaGET
- send-outreach-campaignPOST
- send-outreach-emailPOST
- snooze-cardPOST
- sourceGET
- source_branchesGET
- source_promotePOST
- source_repairPOST
- source_statusGET
- source_validatePOST
- statsGET
- summarize-callPOST
- treeGET
- unarchive-cardPOST
- updatePATCH
- update-cardPUT
- update-columnPUT
- update-prospect-metadataPOST
- update-quote-statusPOST
- update_metaPATCH
- versionGET
Composition
Errors / when it fails
- Duplicate column IDs detected — each column must have a unique id
- Fails unless:
columns.size() <= 1 || columns.all(c, columns.filter(c2, c2.id == c.id).size() == 1) - Duplicate card_action IDs detected — each action must have a unique id
- Fails unless:
card_actions.size() <= 1 || card_actions.all(a, card_actions.filter(a2, a2.id == a.id).size() == 1)
Validation rules
- email_element_slug is not set — send-outreach-email and send-outreach-campaign will fail at runtime
- phone_number_element_slug is not set — dial-card will fail at runtime
- Board has no columns — add columns matching your nine-stage pursuit pipeline
Sales Board (sales-board)
Category: boards | Form: | Symbol: Sb
Pursuit-based sales pipeline — each card is a long-lived sales case from ICP match to won/lost
Sales pipeline board. Each card is a pursuit — a long-lived sales case that lives on the board from inbox/triage through to won/lost. Cards in the inbox column carry prospect_* metadata (prospect_company, prospect_name, prospect_role, prospect_linkedin_url, etc.) populated by research, LinkedIn import, or the manual composer; cards bonded to a permanent Organization + Contact(s) carry organization_id + contact_ids in metadata. Nine-stage kanban (skippable): prospect_pool → enriched → linkedin → email → phone → followup → meeting → quote → closed. Stage is a free field on the card — reps can jump steps for warm prospects. Outreach ops respect consent on the bonded Contact’s endpoints. ERP order history attaches to the bonded Organization by org_no/domain. Legacy flat fields (account_name, contact_email, deal_value, win_probability, expected_close_date) remain available for backward compatibility and out-of-band forecasting.
Guide
Pursuit-based sales pipeline — each card is a long-lived sales case from ICP match to won/lost
What It Does
Sales-board is a kanban board specialised for B2B sales. Each card is a
pursuit — a long-lived sales case that lives on the board from inbox/triage
through to won/lost. It extends the generic board element type with
pursuit-specific card fields (bonds to organizations + contacts, outcome +
reason), a nine-stage pipeline, and a large set of sales operations: prospect
intake, ICP-match ingest, email/LinkedIn/phone outreach, meeting and quote
logging, AI-assisted message composition, and a stage-weighted revenue forecast.
The pipeline is a nine-stage kanban (prospect_pool → enriched → linkedin → email → phone → followup → meeting → quote → closed). Stages are freely
skippable — a warm prospect can jump straight from enrichment to quote. Cards in
the inbox column carry prospect_* metadata (company, name, role, LinkedIn URL,
org-number) populated by research, LinkedIn import, or the manual composer; once
a prospect responds, a card is promoted to bond a permanent organizations row
plus one or more contacts rows, and outreach ops then respect consent on the
bonded contact’s endpoints.
A sales-board is a data element (activity_type: data), not a flow element: it
exposes an HTTP card/column API rather than wire ports. It accepts rate-limit,
auth-policy, and prompt modifiers, and references contacts,
organizations, email, phone-number, and prompt elements in its spec and
ops. Its runtime states are provisioned (initial), active, and error.
Element Definition
| Property | Value |
|---|---|
| Type | sales-board |
| Category | boards |
| Form | atom |
| Symbol | Sb |
| Icon | trending_up / #10B981 |
| Workbench | workbench-board-panel |
Properties
| Field | Type | Default | Description |
|---|---|---|---|
research_agent_slug | string | helm | Slug of the agent dispatched by research-prospect |
outreach_agent_id | string (uuid) | — | Agent element that drafts outbound messages via the compose-message flow. Resolved by the designated-agent cascade (board-level, then circle, then first agent). Leave unset for circle defaults |
email_element_slug | string | — | Slug of the io/email element used for outreach sends |
phone_number_element_slug | string | — | Slug of the io/phone-number element used for dial-card |
pursuit_mode | boolean | true | When true, cards are pursuits — long-lived cases that convert from speculative prospect to bonded Organization + Contact(s) on first real response |
card_actions | array | [] | Declarative per-card action buttons; each entry (id, label, invokes) surfaces on the card face and is callable via run-card-action. Same shape as the base board’s card_actions |
columns | array | 9-stage default | Kanban columns. Each requires id, name, phase (todo/in_progress/closed); optional position, wip_limit, color, stage. Default seeds one column per stage, id == stage.key |
stages | array | 9-stage default | The canonical nine-stage pipeline (step, name, key, intent). Panel-hidden; defines the kanban column set |
The nine default stages (step/key): 0 prospect_pool, 1 enriched,
2 linkedin, 3 email, 4 phone, 5 followup, 6 meeting, 7 quote,
8 closed. A column’s optional stage field maps it to a pursuit stage; on
card create/move into that column, metadata.stage is seeded/synced from the
mapping. Multiple columns may map to the same stage (e.g. Won and Lost both →
closed, distinguished by outcome).
Modifiers & References
| Relationship | Elements |
|---|---|
| Attaches (modifiers) | rate-limit, auth-policy, prompt |
| Uses (spec/op references) | contacts, organizations, prompt, email, phone-number |
Capabilities
| Capability | Description |
|---|---|
pursuit-tracking | Long-lived sales pursuits bonded to Organization + Contact records |
prospect-intake | Inbox-column prospect intake before conversion to permanent records |
icp-match-ingest | Filter a data/leads store against an ICP modifier and open pursuits for matches |
email-outreach | Outreach email sending via the linked io/email element |
call-outreach | Outbound SIP calls via the linked io/phone-number element with live transcript capture |
linkedin-touches | LinkedIn interaction logging with manual approval gate for requests and messages |
meetings | Meeting scheduling and outcome logging |
quotes | Quote lifecycle management (draft/sent/accepted/rejected/expired/withdrawn) |
pipeline-forecast | Stage-weighted revenue forecast across active pursuits |
compose-message | AI-assisted outreach drafting via the chat-as-compose flow |
entity-activity | Unified chronological activity feed across all pursuits for a contact or organization |
queue-view | Per-rep urgency-bucketed work queue across active pursuits |
kanban | Nine-stage kanban with WIP limits, move history, and card references |
Error Codes
| Code | Class | Retryable | Description |
|---|---|---|---|
SALES_BOARD_WIP_EXCEEDED | limit | No | Card move/create would exceed the target column’s wip_limit |
SALES_BOARD_COLUMN_NOT_FOUND | validation | No | column_id does not match any column on this board |
SALES_BOARD_CARD_NOT_FOUND | validation | No | Card does not exist or belongs to a different board |
SALES_EMAIL_ELEMENT_NOT_FOUND | validation | No | spec.email_element_slug does not resolve to a valid io/email element |
SALES_PHONE_ELEMENT_NOT_FOUND | validation | No | spec.phone_number_element_slug does not resolve to a valid io/phone-number element |
SALES_CONSENT_REFUSED | validation | No | Contact has opted out — outreach send refused by the email element’s compliance gate |
SALES_ICP_NOT_FOUND | validation | No | ingest-icp-matches: icp_id/icp_slug does not resolve to an ICP element |
SALES_ICP_AMBIGUOUS | validation | No | ingest-icp-matches: both ids omitted and circle has >1 ICP elements |
SALES_QUOTE_INVALID_TRANSITION | validation | No | update-quote-status: requested transition is not valid for the current state |
SALES_LINKEDIN_TOUCH_NOT_FOUND | validation | No | approve-linkedin-touch: touch_id does not exist or is already sent |
SALES_COMPOSE_NO_CHANNELS | validation | No | compose-message-channels: no actionable messaging channels for this card’s contact |
SALES_PROMPT_NOT_FOUND | validation | No | compose-message-draft: prompt_slug does not resolve to a prompt element |
Operations
Operations are invoked at POST /api/{circle}/{slug}/ops/{op} (the path column
below is the op’s declared route). Sales-board overrides the card-shaped ops to
add pursuit fields and adds the sales-specific ops; it also inherits the shared
kanban ops (move-card, reorder-card, archive-card, unarchive-card,
delete-card, add-ref, remove-ref, add-column, update-column,
remove-column, add-file, get-file, list-files, delete-file,
move-history).
Lifecycle & provisioning
provision
POST provision · auth: write
Auto-provision sibling elements for this board (reads the scaffold’s provisions
list, creates siblings in the same circle, writes resolved slugs back to spec).
Idempotent — skips elements that already exist.
apply-scaffold
POST apply-scaffold · auth: write
Apply a named scaffold (e.g. swedish-b2b) to upgrade a blank board: writes
spec._scaffold, then re-runs the provision flow. Requires scaffold.
Intake
intake-prospect
POST intake-prospect · auth: write
The rep’s primary intake verb. Creates a pursuit card in the inbox column with
prospect_* metadata parsed from a flexible input (LinkedIn URL, company name,
or “Name @ Company”). The card carries no organization_id/contact_ids yet
(speculative, no consent). When dedup_on is true (default), an existing inbox
card matching prospect_org_no or prospect_email is returned instead of
creating a duplicate.
mass-intake-prospects
POST mass-intake-prospects · auth: write
Bulk create inbox cards from a list of prospects (items, same shape as
intake-prospect). Per-item dedup is inherited; item-level errors don’t abort
the batch. Returns created, deduplicated, failed, and per-item results.
update-prospect-metadata
POST update-prospect-metadata · auth: write
Write research findings back onto an inbox card (card_id required). Idempotent;
shallow-merges the enrichment JSONB so multiple research passes accumulate.
Card operations
add-card
POST cards · auth: write
Create a pursuit card in a column (title required). Pass organization_id +
contact_ids to bond permanent records; outcome (won/lost) +
outcome_reason for terminal cards; plus standard card fields (description,
column_id, phase, position, assignees, labels, due_date, priority,
tags). When the target column declares a stage, the card is seeded with it.
update-card
PUT cards/{card_id} · auth: write
Update any fields on a pursuit card (card_id required); only provided fields
change. Bonds, outcome, and outcome_reason are all updatable here.
get-card
GET cards/{card_id} · auth: read
Return full card detail including bonds, outcome, references, children, and file
count.
list-cards
GET cards · auth: read
List pursuit cards with optional filters: column_id, phase, assignee,
label, archived (default false), limit (default 100), offset
(default 0).
board-summary
GET summary · auth: read
Pipeline overview with per-column card count, total value (newest non-withdrawn
quote per card), weighted value (value × stage probability), and deal count.
Sales-specific
pipeline-forecast
GET forecast · auth: read
Stage-weighted revenue forecast across all active (non-archived, non-closed)
pursuits. Picks each pursuit’s newest non-withdrawn quote and multiplies its
amount by a stage probability (prospect_pool=0.05, enriched=0.10,
linkedin=0.15, email=0.20, phone=0.30, followup=0.35, meeting=0.50,
quote=0.75). Optional from_date/to_date and metric_type
(quote_value default, or quote_count). Returns total_value,
weighted_value, deal_count, and per-stage breakdown.
add-pursuit-note / list-pursuit-notes
POST cards/{card_id}/notes (write) · GET cards/{card_id}/notes (read)
Append-only notes on a pursuit. kind distinguishes types
(note/signal/objection/commitment/followup-due, free-form). Notes are
immutable; correct one by adding another. List returns newest-first.
add-linkedin-touch / approve-linkedin-touch / list-linkedin-touches
POST cards/{card_id}/linkedin-touches (write) ·
POST linkedin-touches/{touch_id}/approve (write) ·
GET cards/{card_id}/linkedin-touches (read)
Log a LinkedIn touch (visit/connection_request/message). Visits
auto-status sent; requests and messages default to draft and must flow
through approve-linkedin-touch (flips draft → sent, stamps sent_at) before
being treated as sent. This is a data-store only — it does not automate the
LinkedIn interaction.
log-meeting / list-meetings
POST cards/{card_id}/meetings (write) · GET cards/{card_id}/meetings (read)
Record a meeting (visit/video/phone_meeting/other). scheduled_at and
occurred_at are independent (upcoming vs completed vs reconstructed). List
orders newest-first by occurred_at when set.
add-quote / update-quote-status / list-quotes
POST cards/{card_id}/quotes (write) · POST quotes/{quote_id}/status (write) ·
GET cards/{card_id}/quotes (read)
Attach a quote (defaults to status: draft; amount_cents + currency are
flat forecasting fields; file_ref points to a PDF stored via add-file).
update-quote-status advances through draft/sent/accepted/rejected/expired/ withdrawn, set-once stamping sent_at/responded_at.
list-entity-activity
POST list-entity-activity · auth: read
Unified chronological feed across every pursuit bonded to a contact_id OR an
organization_id (exactly one required). Unions notes, LinkedIn touches,
meetings, and quotes into one newest-first stream.
snooze-card
POST cards/{card_id}/snooze · auth: write
Snooze a pursuit for days (0–90) — hides it from the Queue until then. days = 0 clears the snooze. Card-level (not per-user) in v1.
list-queue
POST list-queue · auth: read
“My work today” — buckets every active card into overdue/today/later by
stage thresholds. Optional owner (user UUID) filters to that assignee;
include_later (default false) and limit (default 50) control the
response.
ingest-icp-matches
POST ingest-icp-matches · auth: write
Filter the circle’s leads against an ICP element’s rules and call open-pursuit
for every match. ICP selection: icp_id, else icp_slug, else auto-detect when
exactly one ICP exists. Default stages filter ['qualified', 'enriched'].
Returns candidates evaluated, matches, pursuits created, and per-lead errors.
open-pursuit
POST open-pursuit · auth: write
Canonical entry for ERP-backed pursuits. Pass organization_id (required) and
optional contact_ids; the pursuit starts at initial_stage (default
prospect_pool; cannot be closed — close via mark-outcome).
dial-card / list-calls / list-call-segments / summarize-call
POST dial-card (write) · POST list-calls (read) ·
POST list-call-segments (read) · POST summarize-call (write)
Place an outbound call via the phone_number_element_id’s SIP connector
(card_id + phone_number_element_id required); call history lives in
circle_*.calls. list-calls scopes by card_id or returns the board log;
list-call-segments reads transcribed peer-side utterances for a call_id;
summarize-call runs a brain turn over the transcript and appends the summary
as a note.
mark-outcome
POST mark-outcome · auth: write
Record a deal outcome (won/lost/no_answer/follow_up). When
follow_up, optionally auto-creates a follow-up card in follow_up_column_id.
Outreach prompts (modifier sockets)
attach-outreach-prompt / detach-outreach-prompt / list-outreach-prompts
POST attach-outreach-prompt (write) · POST detach-outreach-prompt (write) ·
POST list-outreach-prompts (read)
Bind a prompt element to the board under a slot_name (upsert by slot,
optional priority) so it appears in the compose-message picker. Detach by
slot_name. List resolves attached prompts to full spec.
Chat-as-compose
compose-message-channels (step 1)
POST compose-message-channels · auth: read
For a card_id, returns the messaging channels actually sendable: the
intersection of the contact’s verified+consented endpoints with the circle’s
bonded io/* elements. Returns an empty channels array with a reason when
nothing is actionable.
compose-message-options (step 2)
POST compose-message-options · auth: read
For a channel, lists prompt elements in the library whose spec.channel
matches (plus unset as fallback). Optional intent filter.
compose-message-draft (step 3)
POST compose-message-draft · auth: write
Draft an outbound message with AI: given card_id + channel + handle and a
prompt_slug (or free-form custom_prompt), resolves the contact and designated
agent, renders the prompts, calls the LLM, and returns the drafted body.
Idempotent per draft_id.
Outreach send
add-note-to-card
POST add-note-to-card · auth: write
Append a timestamped free-text note (card_id + note required).
send-outreach-email
POST send-outreach-email · auth: write
Send an outreach email via spec.email_element_slug (card_id + subject +
body required; content_type text/html). The email element’s compliance
gates (suppression, consent, rate limit) apply — an opted-out contact is refused
with a structured error.
send-outreach-campaign
POST send-outreach-campaign · auth: write
Like send-outreach-email but uses the email element’s tracked send for
open/click/reply tracking; consent is strictly enforced (consent_status
must be active). Optional campaign_tag.
research-prospect
POST research-prospect · auth: write
Run the research agent (spec.research_agent_slug, default helm) on a single
card (card_id required). The agent synthesizes news, signals, LinkedIn depth,
and public records into an approach plan and writes findings back via
update-prospect-metadata. Returns a generation_id for progress streaming.
Quick Start
Create a sales board
POST /api/{circle}/{project}/
Content-Type: application/json
{
"element_type": "sales-board",
"slug": "swedish-b2b",
"name": "Swedish B2B Pipeline",
"spec": {
"email_element_slug": "outreach-mail",
"phone_number_element_slug": "rep-line"
}
}
The board defaults to nine columns (one per stage). Provide
email_element_slug / phone_number_element_slug up front so the outreach and
dial ops resolve their siblings.
Intake a prospect into the inbox
POST /api/{circle}/{project}/swedish-b2b/ops/intake-prospect
Content-Type: application/json
{
"prospect_company": "Acme AB",
"prospect_name": "Anna Karlsson",
"prospect_role": "CTO",
"prospect_org_no": "556677-8899",
"prospect_source": "allabolag"
}
Enrich, then read the forecast
POST /api/{circle}/{project}/swedish-b2b/ops/research-prospect
Content-Type: application/json
{ "card_id": "<card-uuid>" }
GET /api/{circle}/{project}/swedish-b2b/ops/pipeline-forecast
Common Mistakes
Duplicate column IDs.
Every column in spec.columns must have a unique id — duplicates corrupt card
routing and fail spec validation (column_ids_unique). The same uniqueness rule
applies to card_actions[].id (card_action_ids_unique).
Calling outreach ops without the sibling slugs set.
send-outreach-email and send-outreach-campaign need spec.email_element_slug;
dial-card needs a resolvable io/phone-number element. Unset slugs are flagged
as spec warnings and the ops fail at runtime
(SALES_EMAIL_ELEMENT_NOT_FOUND / SALES_PHONE_ELEMENT_NOT_FOUND).
Removing every column. A board with no columns has nowhere to put pursuits — keep columns matching your nine-stage pipeline. The empty-columns case is flagged as a spec warning.
Sending to an opted-out contact.
Outreach respects the email element’s compliance gate; a contact that has opted
out is refused with SALES_CONSENT_REFUSED, not silently dropped.
Closing via open-pursuit’s initial_stage.
initial_stage cannot be closed — record the terminal outcome with
mark-outcome (or set outcome on the card) instead.
Sending an already-sent LinkedIn message again.
connection_request and message touches start as draft and must be approved
once via approve-linkedin-touch. Re-approving an already-sent touch returns
SALES_LINKEDIN_TOUCH_NOT_FOUND (no state change).
Relationships
- Attaches to: rate-limit, auth-policy, prompt
- Uses: contacts, organizations, prompt, email, phone-number
Capabilities
- pursuit-tracking: Long-lived sales pursuits bonded to Organization + Contact records
- prospect-intake: inbox-column prospect intake (intake-prospect, mass-intake-prospects) before conversion to permanent records
- icp-match-ingest: Filter a data/leads store against an ICP modifier and open pursuits for matches via ingest-icp-matches
- email-outreach: Outreach email sending via linked io/email element (send-outreach-email, send-outreach-campaign)
- call-outreach: Outbound SIP calls via linked io/phone-number element (dial-card) with live transcript capture
- linkedin-touches: LinkedIn interaction logging with manual approval gate for connection requests and messages
- meetings: Meeting scheduling and outcome logging (log-meeting, list-meetings)
- quotes: Quote lifecycle management — draft/sent/accepted/rejected/expired/withdrawn (add-quote, update-quote-status)
- pipeline-forecast: Stage-weighted revenue forecast across active pursuits (pipeline-forecast)
- compose-message: AI-assisted outreach drafting via chat-as-compose flow (compose-message-channels/options/draft)
- entity-activity: Unified chronological activity feed across all pursuits for a contact or organization
- queue-view: Per-rep urgency-bucketed work queue across active pursuits (list-queue)
- kanban: Nine-stage kanban with WIP limits, move history, and card references
Properties
| Property | Type | Default | Description |
|---|---|---|---|
research_agent_slug | string | — | Slug of the agent dispatched by research-run. Defaults to ‘helm’. |
outreach_agent_id | string | — | UUID of the agent element that drafts outbound messages via the compose-message flow. Resolved by the designated-agent cascade: this board-level setting takes precedence, then the circle’s own outreach_agent_id, then the first agent element in the circle. Leave unset to use circle defaults. |
email_element_slug | string | — | Slug of the io/email element used for outreach sends. |
phone_number_element_slug | string | — | Slug of the io/phone-number element used for dial-card. |
pursuit_mode | boolean | true | When true (default), cards are Pursuits — long-lived sales cases that start bonded to a speculative Lead and convert to bonded Organization + Contact(s) on first real response. |
card_actions | array | [] | Declarative card-level actions — each entry surfaces as a button on the card face (rendered by portal from the _actions HATEOAS list on get-card) and is callable via the run-card-action op. Same shape + semantics as the base board element’s card_actions field; surfaced here so sales-board instances can wire per-card invocations (e.g. “Call customer”, “Process deal”) without having to use the generic board type. See the base board’s properties.yaml for the authoritative shape documentation. |
columns | array | [{"id":"prospect_pool","name":"Prospect pool","phase":"todo","position":0,"stage":"prospect_pool"},{"id":"enriched","name":"Enriched","phase":"in_progress","position":1,"stage":"enriched"},{"id":"linkedin","name":"LinkedIn warm-up","phase":"in_progress","position":2,"stage":"linkedin"},{"id":"email","name":"Email outreach","phase":"in_progress","position":3,"stage":"email"},{"id":"phone","name":"Phone","phase":"in_progress","position":4,"stage":"phone"},{"id":"followup","name":"Follow-up","phase":"in_progress","position":5,"stage":"followup"},{"id":"meeting","name":"Meeting","phase":"in_progress","position":6,"stage":"meeting"},{"id":"quote","name":"Quote","phase":"in_progress","position":7,"stage":"quote"},{"id":"closed","name":"Closed","phase":"closed","position":8,"stage":"closed"}] | Kanban columns for this sales board. Sales-specific extension: each column carries an optional stage field that maps the column to one of the 9 pursuit stages. When a card is created or moved into the column, metadata.stage is seeded/synced from this mapping, so the kanban position and the pursuit lifecycle stay in lock-step without the rep having to set stage manually.Default: one column per stage (9 columns), id == stage.key so reps can address them by name ( column_id: "meeting") right after board create — no manual add-column calls needed (BUGS-684). |
stages | array | [{"intent":"ICP-matched companies in light-scan","key":"prospect_pool","name":"Prospect pool","step":0},{"intent":"Detailed person/company info attached; manual fields welcome","key":"enriched","name":"Enriched","step":1},{"intent":"Profile visit + connection request + message (manual approval)","key":"linkedin","name":"LinkedIn warm-up","step":2},{"intent":"Editable email with optional product catalog; manual control","key":"email","name":"Email outreach","step":3},{"intent":"Call placed, recorded, transcribed, action points extracted","key":"phone","name":"Phone","step":4},{"intent":"Thank-you summary; action items; manually approved outbound","key":"followup","name":"Follow-up","step":5},{"intent":"Physical or video meeting with prospect","key":"meeting","name":"Meeting","step":6},{"intent":"Offert sent; attached to card; awaiting response","key":"quote","name":"Quote","step":7},{"intent":"Won or Lost — outcome captured with reason","key":"closed","name":"Closed","step":8}] | The nine-stage kanban pipeline (Säljstöd). Stages are freely skippable — a card may jump from Step 1 (enrichment) directly to Step 7 (quote) when a prospect is already warm. Stage progression is not enforced by the board; reps set the stage field on cards directly. The stage array here defines the canonical kanban column set. |
Operations
activity
Get /ops/activity | Auth: Read
Get activity events for this element
Scope depends on element capabilities: individual elements query by element_id, project-form elements with activity-scope-members include member activities, circle-level elements with activity-scope-all query the entire circle. Gracefully returns empty list if activities table is missing (old circles).
add-card
Post /ops/cards | Auth: Write
Create a new pursuit card in a column
Creates a pursuit card in the specified column. Position defaults to the bottom of the column. Phase defaults to “todo”. Column can be specified by ID, name, or phase alias. Pursuit bonds: pass organization_id + contact_ids to root the pursuit in permanent records. Bonds are stored in card metadata and drive consent-aware outreach. Cards in the inbox column may carry prospect_* metadata (prospect_company / prospect_name / prospect_role / prospect_linkedin_url etc.) for pre-conversion pursuits sourced from research or LinkedIn import. Stage: one of prospect_pool, enriched, linkedin, email, phone, followup, meeting, quote, closed. Free-field — not enforced by column order. Skips allowed per customer requirement. When the target column declares a
stagemapping, the card is seeded with that stage automatically if the caller doesn’t provide one.
add-column
Post /ops/columns | Auth: Write
Add a new column to the board
Creates a column with id, name, and phase (required). The display label field is ‘name’ (not ‘title’). Phase accepts friendly names: Backlog/To Do→todo, Sprint/In Progress→in_progress, Done/Review→closed. Use position gaps (e.g., 15 between 10 and 20) to insert between existing columns without renumbering.
add-file
Post /ops/cards/{card_id}/files | Auth: Write
Attach a file to a card
Uploads a file to the card’s CAS folder at files/{card_id}/{filename}. Content is base64-encoded in the request body. Existing files with the same name are overwritten (upsert). Creates a DB index entry for fast listing. Maximum filename length is 255 characters; path separators are rejected.
add-linkedin-touch
Post /ops/cards/{card_id}/linkedin-touches | Auth: Write
Log a LinkedIn touch against a pursuit (visit | connection_request | message)
Writes to the per-circle
pursuit_linkedin_toucheschild table (lazily bootstrapped). Captures Säljstöd Step 2 activity:• kind=visit — profile visit, auto-status=‘sent’ (no approval gate, as visits are passive). • kind=connection_request — defaults to status=‘draft’ so the rep can review before the connection actually goes out. • kind=message — requires a body, defaults to status=‘draft’. Per customer requirement “meddelande ska godkännas manuellt”, messages must flow through approve-linkedin-touch before they’re treated as sent.
This op is a data-store only — it does NOT automate the LinkedIn interaction. Outbound LinkedIn automation is a separate io/ connector track. Sales reps log what they did here; the board surfaces the history per pursuit.
add-note-to-card
Post /ops/add-note-to-card | Auth: Write
Append a free-text note to a card
Appends a timestamped note to the card’s notes log. Useful for post-call observations, follow-up reminders, or context that doesn’t fit in the card title.
add-pursuit-note
Post /ops/cards/{card_id}/notes | Auth: Write
Append an append-only note to a pursuit
Writes to the per-circle
pursuit_noteschild table (lazily bootstrapped on first use — no migration needed). Notes are immutable; to correct one, add a new note. Survives the lead → contact conversion seam: notes stay bonded to the pursuit card regardless of which permanent records the pursuit is bonded to over time. Usekindto distinguish note types. Default ‘note’ is a free rep observation. Other suggested kinds: ‘signal’ (buying signal picked up), ‘objection’, ‘commitment’, ‘followup-due’. These are free-form — filtering happens in the reader.
add-quote
Post /ops/cards/{card_id}/quotes | Auth: Write
Attach a quote (offert) to a pursuit
Writes to per-circle
pursuit_quotes(lazy CREATE TABLE). Step 7 in Säljstöd — the formal proposal. Defaults to status=‘draft’; call update-quote-status to advance through sent → accepted|rejected|expired. file_ref is an opaque pointer to the PDF stored via the board’s existing add-file pipeline — store whatever resolver key add-file returns, then the UI can download via get-file. amount_cents + currency are flat fields for forecasting. valid_until is a date (not datetime) — the quote expires at end-of-day in the rep’s timezone, not a specific clock instant.
add-ref
Post /ops/cards/{card_id}/refs | Auth: Write
Add a typed reference between cards
Creates a typed edge from this card to the target. relation=blocks automatically creates the inverse blocked_by on the target card. relation=parent makes this card a child of the target. Validates against circular references (BOARD_CIRCULAR_REF) and self-references (BOARD_SELF_REF).
apply-scaffold
Post /ops/apply-scaffold | Auth: Write
Apply a named scaffold to this board (retroactive provisioning)
Like provision but with an explicit scaffold template name. Writes spec._scaffold to the given name, then re-runs the provision flow to materialize that scaffold’s sibling elements. Use this when you created a blank sales-board and want to upgrade it to a preset (e.g., swedish-b2b), or when swapping between regional scaffolds.
approve-linkedin-touch
Post /ops/linkedin-touches/{touch_id}/approve | Auth: Write
Approve a draft LinkedIn touch — flips status draft → sent
Manual-approval step for LinkedIn connection requests and messages. Records the approver UUID and timestamp, transitions status ‘draft’ → ‘sent’, stamps sent_at=now. Idempotent by effect: if the touch is already sent, the update returns NotFound (no state change). Optional
evidencemerges into the row’s evidence blob — use this to capture what actually went out (LinkedIn activity URL, message ID from the automation tool, screenshot of the outgoing message).
archive-card
Post /ops/cards/{card_id}/archive | Auth: Write
Archive a card (soft-remove from board view)
Archived cards are hidden from default list_cards results but can be queried with filter=archived. Archive rather than delete to preserve history and references. Archived cards do not block other cards.
attach-outreach-prompt
Post /ops/attach-outreach-prompt | Auth: Write
Attach a prompt element to this board as an outreach option (a ‘socket’)
Creates an element_modifiers row binding the prompt element to this board with modifier_kind=“prompt” and a user-chosen slot_name. The slot_name is what the user will see in the compose-message option picker (e.g. “warm_intro”, “follow_up”). Multiple prompts can be attached under different slot_names; the same slot_name replaces the existing attachment (upsert semantics).
attachments
Get /ops/attachments | Auth: Read
List all modifiers and resources attached to this element
Returns both modifiers (policy enforcement) and resources (data injection) with is_modifier flag to distinguish. Items in the generated MODIFIER_TYPES list are modifiers; everything else is a resource. Includes cascade_policy and version pin info.
batch_stats
Get /ops/batch_stats | Auth: Read
Get per-element statistics for all children of this element
Returns per-child stats plus an aggregate. Most meaningful on compound or manifest form elements (repositories, circles, projects); atoms have no children so the result is an empty children array with a zeroed aggregate. Uses efficient GROUP BY SQL. Weighted averages for eval scores.
board-summary
Get /ops/summary | Auth: Read
Pipeline overview with deal totals per stage
Returns a board overview with per-column counts and quote-derived aggregates. Each column includes: card count, total value (sum of the newest non-withdrawn quote per card), weighted value (value × stage probability), and deal count. Useful for pipeline health dashboards. Phase 5 rework: see pipeline-forecast for the weighting table.
compose
Post /ops/compose | Auth: Execute
Batch add and remove modifiers on this element in a single call
Declarative composition: add modifiers by ref path (slug or path@version) and remove by attachment ID, all in one atomic call on the target element. Each ‘add’ entry resolves the source element, validates topology, attaches with optional priority and cascade policy. Each ‘remove’ entry deletes the attachment row. Returns a summary of what was added and removed. Example: compose({ add: [{ref: “my-prompt”}, {ref: “rate-limit/api@v2”, priority: 50}], remove: [{attachment_id: “uuid”}] })
compose-message-channels
Post /ops/compose-message-channels | Auth: Read
List actionable messaging channels for a card’s primary contact (chat-as-compose step 1)
Given a card_id (and optionally an explicit contact_id), returns the messaging channels we can actually send on: the intersection of the contact’s verified+consented endpoints (emails / phones / socials / messaging from data/contacts) with the circle’s bonded io/* elements (email, slack, discord, matrix, rocketchat, mattermost, phone-number). Feeds the channel picker Question widget in the compose-message flow. Returns an empty
channelsarray with areasonfield when nothing is actionable — the portal renders that as an empty-state CTA.
compose-message-draft
Post /ops/compose-message-draft | Auth: Write
Draft an outbound message with AI (chat-as-compose step 3)
Third and final step. Given a card_id + channel + handle + prompt_slug (or free-form custom_prompt), resolves the card’s contact and the designated agent, renders the system prompt (contact-context-system) and user prompt (the selected template), calls the LLM, and returns the drafted body. The portal renders the result in a MessageDraft chat widget that the user can edit and send. Idempotent per draft_id. Use compose-message-options first to discover available prompt_slugs.
compose-message-options
Post /ops/compose-message-options | Auth: Read
List prompt options for a channel (chat-as-compose step 2)
Second step of the chat-as-compose flow. Given a channel slug, returns all prompt elements in the circle’s library whose spec.channel matches (or whose channel is unset, as fallback). Each option carries its slug, display_name, intention, and intent tag — the portal renders these as buttons in a Question widget. A “Custom…” option is always included client-side to let the user free-text their intent; no server-side representation needed for that.
context
Get /ops/context | Auth: Read
Get connected elements (graph traversal)
Graph traversal showing all connected elements with their relationship type (contains, contained_by, references, referenced_by, attaches, etc.). Use ?depth=N to control traversal depth (default 1) and ?types=actor,data to filter by element types.
create
Post /ops/create | Auth: Write
Create child element
POST to the parent path — element_type goes in the request body, NOT the URL. Both element_type and slug are required and must be non-empty. Name is derived from slug if omitted. Writes to both Git and PostgreSQL. All elements are stored flat under the circle — no intermediate library wrapper rows.
delete
Delete /ops/delete | Auth: Admin
Delete element (soft delete)
Soft delete — sets state to ‘deleted’ but retains the record. Cannot delete elements that have children (has_no_bond precondition) or active runs. Requires admin auth and confirmation.
delete-card
Delete /ops/cards/{card_id} | Auth: Write
Permanently delete a card
Hard deletes a card and all its references (blocked_by, blocks, related, parent). Subcards (children) are NOT deleted — they become orphans. Prefer archive-card to preserve history.
delete-file
Delete /ops/cards/{card_id}/files/{filename} | Auth: Write
Remove a file attachment from a card
Deletes the file from CAS and removes its DB index entry. The filename is extracted from the URL path. Returns not-found if the file doesn’t exist.
detach-outreach-prompt
Post /ops/detach-outreach-prompt | Auth: Write
Remove a prompt attachment from this board
Deletes the element_modifiers row for (board_id, kind=“prompt”, slot_name).
dial-card
Post /ops/dial-card | Auth: Write
Place an outbound call to the card’s contact phone
Resolves card.metadata.contact_phone (populated by add-card-from-lead) and dials via the specified phone-number element’s SipConnector. Records every attempt in circle_*.calls (card_id, lead_id, sip_call_id, status, started_at) so call history lives with the card. Returns immediately after INVITE — rollout of live RTP audio tap and post- call transcription pipeline comes in a follow-up round.
disable
Post /ops/disable | Auth: Admin
Disable element (hides and prevents use)
Idempotent — safe to call on already-disabled elements. Optionally pass a reason string. Disabled elements cannot be invoked or executed. Inverse of enable.
enable
Post /ops/enable | Auth: Admin
Enable element (makes usable and visible)
Idempotent — safe to call on already-enabled elements. Transitions element to ready/enabled state. Cannot enable deleted elements. Inverse of disable.
export_bundle
Get /ops/export/bundle | Auth: Read
Export element as downloadable git bundle
On non-root-namespace elements, returns a binary git bundle. On root-namespace (circle) elements, dispatch hands off to the circle’s own export_bundle op, which returns a multi-element JSON envelope with one base64 bundle per child element — this is intentional, not an error.
get
Get /ops/get | Auth: Read
Get element details
Element is already resolved by the routing layer — this returns the cached element, not a fresh DB query. Use the path /api/{circle}/{slug} to address elements.
get-card
Get /ops/cards/{card_id} | Auth: Read
Get a pursuit card with full details
Returns full card details including pursuit bonds (organization_id, contact_ids), stage, outcome, references, children, and file count.
get-file
Get /ops/cards/{card_id}/files/{filename} | Auth: Read
Download a file attached to a card
Returns the file content as base64 along with metadata (size, content type, upload timestamp). The filename is extracted from the URL path.
import_bundle
Post /ops/import/bundle | Auth: Write
Import git bundle into element
Accepts a base64-encoded git bundle in the JSON bundle_base64 field. Use overwrite=true to replace existing elements with same slug (default skips duplicates). Imported elements get new UUIDs. Returns counts of imported/skipped elements and any errors.
ingest-icp-matches
Post /ops/ingest-icp-matches | Auth: Write
Filter leads against an ICP modifier and open pursuits for matches
Closes the research → pursuit loop. Reads an ICP element’s rule set (industries, regions, employee/revenue bands, required signals, target roles, excluded keywords, min_score), walks the circle’s leads at specified stages, evaluates each against the rules, and calls open-pursuit for every match. ICP selection priority:
- icp_id (UUID) — explicit element selection.
- icp_slug — lookup by slug (must be an element of type icp).
- Auto-detect — if exactly one ICP exists in the circle, use it. Fails with a helpful error if 0 or >1 ICPs exist.
Idempotency: matched leads are moved to stage=‘assigned’ after a successful open-pursuit call. The next run won’t re-ingest them (default stages filter is {‘qualified’, ‘enriched’}). Match semantics follow the ICP’s match_mode: ‘all’ (AND) or ‘any’ (OR). Excluded keywords are always a short-circuit fail. Returns a summary: candidates evaluated, matches found, pursuits created, and a list of per-lead errors (if any open-pursuit calls failed individually, the others still proceed).
intake-prospect
Post /ops/intake-prospect | Auth: Write
Create a card in the inbox column with prospect data — the manual intake path
The rep’s primary intake verb. Accepts a flexible input shape — paste a LinkedIn URL, type a company name, or “Name @ Company” — and creates a pursuit card in the board’s inbox column (the leftmost column by position, or whichever column matches spec.intake_column_id when set). The card’s metadata is seeded with prospect_* fields parsed from the input. The card has no organization_id / contact_ids yet (it is speculative, with no consent on any contact info). When the prospect first responds, convert-on-response promotes the card by upserting an Organization + Contact with dedup, capturing soft_optin consent, and patching the card’s metadata to clear the prospect_* raw fields and fill organization_id + contact_ids. Idempotency: if prospect_org_no or prospect_email matches an existing inbox card on this board, returns that card_id rather than creating a duplicate (when dedup_on is unset or true).
intention
Get /ops/intention | Auth: Read
Get element intention with full inheritance chain
Returns three levels: direct (this element’s intention), inherited (from category and root), and resolved (final merged intention). Useful for understanding an element’s purpose in context of its hierarchy.
list-call-segments
Post /ops/list-call-segments | Auth: Read
Transcribed utterances for a call — live transcript view
Reads circle_*.call_segments populated by the sales_transcribe_loop. Each row is one VAD-delimited utterance from the prospect side of the call with text + voice_ms + started_at + ended_at. Poll this while a call is in progress to drive a live-transcript UI, and read it once post-call for the final version. Note that segments currently only contain the PEER side — capturing the rep’s own audio needs a browser-side WebRTC tap (separate feature).
list-calls
Post /ops/list-calls | Auth: Read
Call history for a card or the whole board
Filter by card_id to scope. Without it, returns the board’s full recent call log newest-first. Useful for the live-card UI and for agent-side coaching (“how did the last call with this prospect go”).
list-cards
Get /ops/cards | Auth: Read
List pursuit cards with optional filtering
Lists pursuit cards with optional filters. Returns pursuit fields (stage, bonds, outcome) alongside standard card data. Filter by column, phase, assignee, label, or archived status.
list-entity-activity
Post /ops/list-entity-activity | Auth: Read
Unified activity feed across every pursuit bonded to a contact or organization
Backs the Contact / Organization variants of the Entity Detail Surface (spec: .triform/design/board-redesign/06-entity-detail-surface.md). Given a contact_id OR organization_id, finds every card on this sales-board whose metadata references it, then unions all four per-pursuit activity tables (pursuit_notes, pursuit_linkedin_touches, pursuit_meetings, pursuit_quotes) and returns a single chronological stream, newest-first. Exactly one of contact_id / organization_id must be set. Passing both is an error (ambiguous scope); passing neither returns an empty stream rather than listing everything (guardrail against accidental full-scan calls from the UI). Row shape is normalised to match ActivityTimeline’s ActivityEntry — every bucket emits {kind, when, title, body?, status?, card_id, card_title} so the frontend doesn’t need per-kind branching.
list-files
Get /ops/cards/{card_id}/files | Auth: Read
List files attached to a card
Returns metadata for all files attached to the card. Reads from the DB index table for fast listing without walking the CAS tree.
list-linkedin-touches
Get /ops/cards/{card_id}/linkedin-touches | Auth: Read
List LinkedIn touches for a pursuit, newest first
list-meetings
Get /ops/cards/{card_id}/meetings | Auth: Read
List meetings for a pursuit (newest first, by occurred_at if set)
list-outreach-prompts
Post /ops/list-outreach-prompts | Auth: Read
List all prompt elements attached to this board
Returns the prompts currently attached to this board as outreach options, resolved to full prompt spec so the portal can show the slot_name, display_name, intention, channel tag, and intent tag.
list-pursuit-notes
Get /ops/cards/{card_id}/notes | Auth: Read
List notes on a pursuit, newest first
list-queue
Post /ops/list-queue | Auth: Read
My work today — per-user urgency-bucketed cross-pursuit feed
Backs the Queue view (spec: .triform/design/board-redesign/07-queue-view-ux.md). Scans every active (non-archived, no outcome) card on this sales-board and buckets it as Overdue / Today / Later based on stage + stage_days thresholds. Same thresholds
derive_next_actionuses for the per-card next-action so Queue and card face stay in agreement about what’s urgent. v1 is board-scoped (one sales-board at a time). Cross-board aggregation across multiple sales-boards in a circle is a follow-up — most circles currently have one sales-board and the complexity budget for v1 is better spent on the UI primitives. Owner filtering: passowneras a user UUID to include only cards where that user is in the card’s assignees array. Omit or pass empty to include all assignees (team queue view).
list-quotes
Get /ops/cards/{card_id}/quotes | Auth: Read
List quotes for a pursuit, newest first
log-meeting
Post /ops/cards/{card_id}/meetings | Auth: Write
Record a meeting (physical visit, video, phone) on a pursuit
Writes to per-circle
pursuit_meetings(lazy CREATE TABLE). Step 6 in Säljstöd — the real-world contact that usually precedes a quote. Scheduled vs occurred timestamps are independent: scheduled_at without occurred_at = upcoming; both set = completed; only occurred_at set = reconstructed from memory. Summary is free-text (agenda, outcomes, action points). Attendees is a free JSONB shape — keep it simple, the board reads it back as-is.
mark-outcome
Post /ops/mark-outcome | Auth: Write
Record a deal outcome on a card
Sets the outcome on a card (won, lost, no_answer, follow_up). When outcome is “follow_up”, optionally auto-creates a new card in the specified column for the next attempt.
mass-intake-prospects
Post /ops/mass-intake-prospects | Auth: Write
Bulk create inbox cards from a list of prospects
Used by: • the inline composer’s multi-line paste (one prospect per line), • io/* connectors (LinkedIn ingest, Allabolag bulk lookup), • intake-prospects-from-icp’s research agent write-back, • CSV importers.
Each item has the same shape as intake-prospect’s input. Idempotency and dedup behaviour are inherited from intake-prospect — duplicates against the inbox return the existing card_id without creating new cards. Errors on individual items don’t abort the batch; the response enumerates per-item outcomes.
move-card
Post /ops/cards/{card_id}/move | Auth: Write
Move a card to a different column
Moves the card to the target column. Column can be specified by ID, name, or phase alias (Backlog, In Progress, Done, etc.). Position defaults to bottom of the target column. This is tracked in the move history audit trail. Enforces WIP limits on the target column — fails with BOARD_WIP_EXCEEDED if the limit would be breached. Emits board.card.moved NATS event.
move-history
Get /ops/cards/{card_id}/moves | Auth: Read
Get the move history for a card
Returns the audit trail of column transitions for this card. Each entry includes from_column, to_column, moved_by (actor slug), and timestamp. Ordered newest first.
open-pursuit
Post /ops/open-pursuit | Auth: Write
Open a pursuit — a sales card bonded to an existing organization
The canonical entry point for ERP-backed pursuits. Pass organization_id (and optional contact_ids). The pursuit starts at an explicit initial_stage bonded to the permanent records. For pursuits starting from research candidates, use intake-prospect (which creates a card in the inbox column with prospect_* metadata) + research-prospect (which dispatches the research agent against an existing card). The legacy pre-conversion lead-bond flow is gone.
pipeline-forecast
Get /ops/forecast | Auth: Read
Stage-weighted pipeline revenue forecast
Returns a stage-weighted revenue forecast across all active (non-archived, non-closed) pursuits. For each pursuit, picks the newest non-withdrawn quote and multiplies its amount by a stage-based probability (prospect_pool=0.05, enriched=0.10, linkedin=0.15, email=0.20, phone=0.30, followup=0.35, meeting=0.50, quote=0.75). Groups by column/stage and returns: - total_value: sum of quote amounts per stage - weighted_value: sum of (amount × stage weight) - deal_count: number of pursuits per stage - avg_stage_weight: average weight applied Pursuits without a quote contribute 0 to totals. Phase 5 implementation.
promote
Post /ops/promote | Auth: Admin
Promote element configuration to a target environment
Only for manifest-form elements (projects). Environments advance: dev → demo → live. dev→demo requires member+ role, demo→live requires admin. Freezes member versions at promotion time (creates snapshot). Persists environment config to spec.environments.
provision
Post /ops/provision | Auth: Write
Auto-provision sibling elements for this sales board
Called automatically after board creation. Reads the scaffold’s provisions list and creates sibling elements (data/leads, io/http connectors, variable secrets) in the same circle. Writes resolved slugs back to the board’s spec. Idempotent — skips elements that already exist.
readme
Get /ops/readme | Auth: Read
Get element README.md content
Reads README.md from the element’s git repository. Returns empty content (not an error) if no README exists. Always returns markdown format.
readme_update
Post /ops/readme_update | Auth: Write
Update element README.md content
Creates or overwrites README.md in the element’s git repo. Commits to the draft branch. Content must be provided as a markdown string.
remove-column
Delete /ops/columns/{column_id} | Auth: Admin
Remove a column from the board
Fails with BOARD_COLUMN_NOT_EMPTY if the column has non-archived cards. Archive or move all cards first. The column’s position gap remains available for reuse.
remove-modifier
Post /ops/remove-modifier | Auth: Execute
Remove an attached modifier from this element by attachment ID
Removes a modifier/resource attachment by its row ID. The ID comes from the attachments or context API. This is the reverse of attach — called on the target element, not the source.
remove-ref
Delete /ops/cards/{card_id}/refs/{target_card_id} | Auth: Write
Remove a reference between cards
Removes the reference from this card to the target. If the reference has an inverse (blocks/blocked_by), the inverse is also removed.
reorder-card
Post /ops/cards/{card_id}/reorder | Auth: Write
Change a card’s position within its current column
Changes vertical position (priority) without changing column. Position 0 is the top (highest priority). Other cards in the column are renumbered automatically to maintain order.
research-prospect
Post /ops/research-prospect | Auth: Write
Run the research agent on a single card for deep multi-source enrichment
The post-collapse replacement for research-run. Takes a card_id directly instead of a lead_id, so it works on the new flow where cards in the inbox column ARE the prospects (no separate data/leads element). The research agent reads the card’s prospect_* metadata fields (prospect_company / prospect_name / prospect_role / prospect_org_no / prospect_linkedin_url / etc.), synthesizes news + signals + LinkedIn depth + public records into an approach plan, and writes findings back via a single
update-prospect-metadata { card_id, prospect_fields, enrichment, merge_enrichment: true }call. Re-running refreshes without clobbering thanks to the shallow-merge enrichment semantics. Resolves spec.research_agent_slug (defaults to ‘helm’) and dispatches the agent’s generate op. Returns a generation_id the frontend subscribes to via NATS for progress streaming. Coexists with research-run during the data/leads → cards migration; once leads are gone, research-run is dropped.
restore
Post /ops/restore | Auth: Admin
Restore element to a specific version
Automatically snapshots the current state before restoring (creates a ‘Before restore to vN’ version entry). Writes restored spec to git as .triform/spec.yaml. Git failures warn but don’t fail the operation — DB state is authoritative. Cannot restore deleted elements.
schema
Get /ops/schema | Auth: Read
Get element input/output schema (MCP tools/list compatible)
Returns type-level port schemas from the TypeRegistry — not instance-specific overrides. Includes direction (input/output), required flag, and JSON schema per port. Useful for understanding what data an element accepts and produces.
send-outreach-campaign
Post /ops/send-outreach-campaign | Auth: Write
Send a tracked outreach email with open/click tracking
Same as send-outreach-email but uses io/email.send_tracked for full open/click/reply tracking. The tracking pixel and link rewriting are handled by the email element. Consent gate is strictly enforced — the contact must have consent_status=“active” or the send is refused. Use for formal campaign sends where engagement metrics matter.
send-outreach-email
Post /ops/send-outreach-email | Auth: Write
Send an outreach email to the card’s contact via the linked email element
Resolves card → lead → contact, ensures the contact exists in data/contacts (auto-upserts with consent_source=“outbound_outreach_sent”), then dispatches io/email.send via the element at spec.email_element_slug. Returns the message_id on success. The email element’s compliance gates (suppression, consent, rate limit) apply — if the contact has opted out, the send is refused with a structured error.
snooze-card
Post /ops/cards/{card_id}/snooze | Auth: Write
Snooze a pursuit for N days — hides it from the Queue until then
Writes metadata.next_action_snooze_until = NOW() + days. The Queue op (list-queue) filters cards where this timestamp is in the future. Passing days = 0 clears the snooze (undo path). v1 is card-level (not per-user). Multiple assignees see the same snooze. If that becomes friction in multi-assignee teams we’ll grow a per-user map here; until then the simpler model keeps the Queue filter a single JSONB comparison.
source
Get /ops/source | Auth: Read
Get any file’s content from the element’s git repository
Reads an arbitrary file from the element’s CAS-backed git tree by its relative path. Same store as
readme, just generalized. Path safety: rejects..traversal, leading/, and null bytes. Use this to viewmain.pyfor action elements, asset files for SPAs, etc. Returns empty content (not an error) if the file doesn’t exist.
source_branches
Get /ops/source/branches | Auth: Read
List Source branches for this element
Returns the standard draft/demo/live Source branches, their current commits, and promotion relationships. Use GET /api/{element_path}/ops/source/branches.
source_promote
Post /ops/source/promote | Auth: Write
Promote Source branch forward
Promotes draft to demo or demo to live through the generated element op path. Direct Git pushes to demo/live are blocked by Source policy.
source_repair
Post /ops/source/repair | Auth: Write
Inspect or repair the element Source index
Runs Source repair through the element operation path. Defaults to dry_run=true; set dry_run=false only after reviewing a dry-run report.
source_status
Get /ops/source/status | Auth: Read
Get Source control status for this element
Returns the branch-aware clone URL, checkout commands, current draft commit, child source-link count, portable export summary, Source health, warnings, and auth hints for the addressed element. Use the element-first path: GET /api/{element_path}/ops/source/status.
source_validate
Post /ops/source/validate | Auth: Read
Validate Source branch contents
Validates a Source branch before accepting local Git workflow changes or promotion. Defaults to branch=draft and rejects runtime data, generated output, secret material, and unreadable CAS refs.
stats
Get /ops/stats | Auth: Read
Get aggregate statistics for this element
Health status is computed: error if errors_per_day > 5 or success_rate < 0.8, warning if errors_per_day > 0 or success_rate < 0.95. Firing alerts escalate health to error/warning. Default period is ‘day’. Returns runs_per_day, success_rate, avg_duration_ms, and more.
summarize-call
Post /ops/summarize-call | Auth: Write
Run a brain turn over a call transcript and append the summary to the card
Reads circle_*.call_segments for a call_id, concatenates them into a transcript (speaker-tagged), feeds it to the platform brain with the post-call-summary prompt, and appends the result as a note on the card the call was placed from. Useful for quickly getting a 3-bullet recap + next action after a sales call.
tree
Get /ops/tree | Auth: Read
Get the element’s position in the graph — ancestors, children, references, and subtree statistics
Uses per-circle ElementGraph cache for O(1) lookups. Returns ancestors (containment chain), children (direct), members (references), referenced_by (reverse refs), attachments, and subtree stats. Default depth is 3, max is 10. Pass ?include_metadata=true for name/state on each node.
unarchive-card
Post /ops/cards/{card_id}/unarchive | Auth: Write
Restore an archived card to the board
Restores an archived card, making it visible again in default list-cards results. The card retains all its fields and references. Position defaults to the bottom of the card’s original column.
update
Patch /ops/update | Auth: Write
Update element
Partial update — send only the fields you want to change.
spec,name, andintentionare all independently optional.specMUST be a JSON object when present; deep-merged into the existing spec by default. Empty{"spec":{}}preserves existing spec content but still records a new version (no-op for content, not for version state). To clear/replace the entire spec wholesale send{"spec":{...},"deep":false}. List-typed spec fields use replace semantics (the patch list replaces the existing list, no array merging). Coordinates Git + DB writes. Slug cannot be changed after creation.
update-card
Put /ops/cards/{card_id} | Auth: Write
Update a pursuit card
Updates any fields on a pursuit card. Only provided fields are changed. Pursuit bonds (organization_id, contact_ids), outcome, and outcome_reason can all be updated here.
update-column
Put /ops/columns/{column_id} | Auth: Write
Update column name, phase, WIP limit, or position
Updates column properties. Changing phase does NOT change the phase of cards in the column — card phase is independent. Only provided fields are updated. Set wip_limit to 0 to remove the limit.
update-prospect-metadata
Post /ops/update-prospect-metadata | Auth: Write
Write research findings back onto an inbox card — replaces data/leads.upsert for agent write-back
The op research agents (helm) call to layer enrichment + approach_plan onto a card in the inbox. Idempotent. Shallow-merges the enrichment JSONB so multiple research passes accumulate without clobbering. Replaces the speculative data/leads.upsert flow. A card_id is required — research is always scoped to a specific pursuit. For ICP-driven discovery where no card exists yet, the agent calls intake-prospect (or mass-intake-prospects) instead.
update-quote-status
Post /ops/quotes/{quote_id}/status | Auth: Write
Advance a quote through its lifecycle
Flips status; stamps sent_at on first transition to ‘sent’, responded_at on first transition to ‘accepted’ or ‘rejected’. Timestamps are set-once — repeating a status transition does not re-stamp (preserves the original moment). ‘withdrawn’ is our side pulling the quote; ‘expired’ fires when valid_until passes without a response (can be set manually or by a future cron).
update_meta
Patch /ops/update_meta | Auth: Write
Update element metadata (lightweight merge — does NOT bump version or snapshot spec)
Shallow JSONB merge into element.meta. Top-level keys in the provided value replace existing meta values; other keys are preserved. Used for UI metadata like canvas positions, panel state, viewer preferences. Wire-shape op_name is
update_meta(distinct fromupdate) so SSE subscribers + the cache auto-invalidator can distinguish lightweight metadata changes from spec edits without inspecting the payload. The MutatingElementStore wrapper stamps this op_name on the lifecycle event emitted byupdate_element_metastorage calls.
version
Get /ops/version | Auth: Read
Get current version or full history
Returns current version by default. Pass ?history=true for full version history (up to ?limit=N, default 50). Versions are backed by the element_versions table. Every spec update creates a new version entry.
Error Codes
| Code | Class | Retryable | Description |
|---|---|---|---|
SALES_BOARD_WIP_EXCEEDED | limit | no | Card move or create would exceed the target column’s wip_limit |
SALES_BOARD_COLUMN_NOT_FOUND | validation | no | column_id does not match any column on this sales board |
SALES_BOARD_CARD_NOT_FOUND | validation | no | Card does not exist or belongs to a different board |
SALES_EMAIL_ELEMENT_NOT_FOUND | validation | no | spec.email_element_slug does not resolve to a valid io/email element in this circle |
SALES_PHONE_ELEMENT_NOT_FOUND | validation | no | spec.phone_number_element_slug does not resolve to a valid io/phone-number element in this circle |
SALES_CONSENT_REFUSED | validation | no | Contact has opted out — outreach send refused by the email element’s compliance gate |
SALES_ICP_NOT_FOUND | validation | no | ingest-icp-matches: icp_id or icp_slug does not resolve to an ICP element in this circle |
SALES_ICP_AMBIGUOUS | validation | no | ingest-icp-matches: icp_id and icp_slug both omitted and circle has >1 ICP elements — pass icp_id or icp_slug to disambiguate |
SALES_QUOTE_INVALID_TRANSITION | validation | no | update-quote-status: requested status transition is not valid for the current quote state |
SALES_LINKEDIN_TOUCH_NOT_FOUND | validation | no | approve-linkedin-touch: touch_id does not exist or is already sent |
SALES_COMPOSE_NO_CHANNELS | validation | no | compose-message-channels: no actionable messaging channels for this card’s contact |
SALES_PROMPT_NOT_FOUND | validation | no | compose-message-draft: prompt_slug does not resolve to a prompt element in this circle |
Observability
Defined for this element
Metrics
- sales_pursuit_opened_count
- sales_pursuit_converted_count
- sales_outreach_sent_count
- sales_outreach_response_received_count
- sales_deal_closed_count
- sales_deal_value_eur
- sales_pipeline_forecast_queried_count
- sales_time_in_stage_ms
Events
- sales.pursuit.opened
- sales.pursuit.converted
- sales.outreach.sent
- sales.outreach.response_received
- sales.deal.closed
- sales.pipeline_forecast.queried
- sales.stage.advanced
Pricing / cost
Platform default
Operation costs
- create: free
- update: free
- delete: free
- get: free
- list: free
- invoke: 10000 micro-AU
- tool_use: free
Set it up
- Board Nametext
- Target Industrytext
- Regiontext
- Target Rolestags
- Min Employeesnumber
- Max Employeesnumber