ICP
A saved description of the company or contact worth pursuing — a declarative ruleset that consumer ops like sales-board's ingest-icp-matches read by reference to filter raw research leads down to the ones that become Pursuits.
Working with it
Selecting a ICP reveals its settings in the properties panel; it has no dedicated full-screen workbench.
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
- Encoding who a sales-board should chase — industries, regions, size/revenue bands, buying signals, target roles — so lead ingestion promotes only the matches into Pursuits.
- Reusing a saved LinkedIn search URL as the filter itself (URL mode), routed through the circle's linkedin element at ingest time.
- Keeping several audiences side by side in one circle — an enterprise ICP, an SMB ICP, a campaign ICP — and selecting which one a consumer op uses by reference.
When not to use
- Filtering or validating arbitrary request/response payloads — ICP never sits in a middleware path; use validation or filter-words for that.
- Storing a reusable scalar or config value with no match semantics — that is what variable is for.
- Actually sourcing and opening pursuits — the matching logic lives on the consumer (sales-board.ingest-icp-matches); ICP only declares the rules.
Topology
Attaches to another element as a modifier, shaping that element's behaviour rather than running on its own.
Properties
match_modestring- How populated fields combine. 'all' = AND (every rule must match), 'any' = OR (at least one rule matches). 'all' is typical for ICP; 'any' is useful for exploratory scans.
industriesarray- Target industries. Free-form strings — typically SNI codes (Swedish industry classification) like '62.01' for software development, but can be arbitrary labels ('SaaS', 'fintech', 'healthtech'). Match is substring-insensitive.
regionsarray- Geographic regions. Countries, counties, cities — whatever granularity your research agent reports. Match is substring-insensitive.
min_employeesinteger- Minimum headcount (inclusive). Omit for no lower bound.
max_employeesinteger- Maximum headcount (inclusive). Omit for no upper bound.
min_revenue_centsinteger- Minimum annual revenue in minor units (cents/öre). Currency is implicit per-region (use a second ICP with a converted band if mixing currencies).
max_revenue_centsinteger- Maximum annual revenue in minor units.
required_signalsarray- Buying signals that must be present on the lead for a match. Free-form strings matched against lead.enrichment.signals (if the research agent populated them): 'hiring', 'funding_event', 'news_mention', 'procurement_posting', 'technology_stack_match', etc.
excluded_keywordsarray- Disqualifier strings. If any appears in the lead's company, name, or enrichment.notes, the lead is excluded regardless of other matches. Use for known bad fits ('student', 'sole trader', defunct brands).
target_rolesarray- Decision-maker roles to target. When specified, leads whose role field doesn't match any entry are excluded. Match is substring-insensitive ('VP Sales' matches 'VP of Sales'). Leave empty to match company-only leads (no person attached yet).
min_scoreinteger- Minimum agent-computed fit score (0-100) the lead must carry. Filters out low-quality research output before it reaches the pipeline.
notesstring- Free-form description of the target — who this ICP is for, why it exists.
source_urlstring- A saved LinkedIn search URL that already encodes the target filter. (Apollo, allabolag and other sources will activate when those integrations ship; for now LinkedIn is the only route that completes end-to-end — pasting a non-LinkedIn URL will fail at ingest time with a clear "add an io/… element" message.) When set, the ICP's sourcing phase fetches leads via the matching io/* element in the circle (io/linkedin for linkedin.com URLs). The structured firmographic/behavioral rules below still run as a post-qualification filter if populated — leave them empty to accept every sourced lead.
Capabilities
Inherited from modifiers
- Evaluate
- Observe
Operations
- attachPOST
- deleteDELETE
- detachPOST
- disablePOST
- enablePOST
- evaluatePOST
- getGET
- get_attached_modifiersGET
- intentionGET
- list_attachmentsGET
- readme_updatePOST
- schemaGET
- updatePATCH
Ports
Inputs
- match_modeconfig
- industriesconfig
- regionsconfig
- min_employeesconfig
- max_employeesconfig
- min_revenue_centsconfig
- max_revenue_centsconfig
- required_signalsconfig
- excluded_keywordsconfig
- target_rolesconfig
- min_scoreconfig
- source_urlconfig
Composition
Errors / when it fails
- min_employees must be ≤ max_employees
- Fails unless:
min_employees <= max_employees - min_revenue_cents must be ≤ max_revenue_cents
- Fails unless:
min_revenue_cents <= max_revenue_cents
Validation rules
- No source_url and no structured rules populated — this ICP has nothing to match or source against
ICP (icp)
Category: modifiers | Form: | Symbol: Ic
Ideal Customer Profile — declarative match rules that filter leads into pursuits
An ICP declares the shape of a company/contact worth pursuing. Two modes of definition, both consumed by sales-board.ingest-icp-matches: URL mode (source_url set) — paste a saved LinkedIn search URL that already encodes the filter server-side. ingest-icp-matches routes the URL through io/linkedin in the circle, which publishes people-vertical results as cards on the sales-board’s first column and persists company-vertical results into data/organizations. The URL IS the filter. Apollo, allabolag and other sources will activate as those io/* elements ship the ingest-search-url contract; for now LinkedIn is the only route that completes end-to-end. Rules mode (structured fields) — industries, regions, company size band, revenue band, buying signals, target roles, excluded keywords, min score. Rules are additive: every populated field narrows the match. A lead passes only if it satisfies ALL populated fields (AND), unless match_mode=‘any’ is set (OR). The modes compose: if both source_url AND structured rules are populated, URL-sourced leads are post-qualified against the rules before opening pursuits. Not a middleware — runs at sales-board op invocation time, not per-request. Multiple ICPs can coexist per circle (e.g. one for enterprise, one for SMB, one for a specific campaign). Sales-board selects which ICP to use by reference.
Guide
Ideal Customer Profile — declarative match rules that filter leads into pursuits.
What It Does
An ICP declares the shape of a company or contact worth pursuing. It is a
purely declarative ruleset (a config modifier) — it has no operations of its
own and never sits in a request/response middleware path. Instead, specific
consumer ops (primarily sales-board.ingest-icp-matches) read the ICP’s spec by
reference and filter raw research leads against its rules, promoting matches into
Pursuits.
There are two ways to define an ICP, and they compose:
- Rules mode (structured fields) — industries, regions, company-size band,
revenue band, buying signals, target roles, excluded keywords, and a minimum
fit score. Rules are additive: every populated field narrows the match. A lead
passes only if it satisfies all populated fields (AND) unless
match_modeis set toany(OR). - URL mode (
source_urlset) — a saved LinkedIn search URL that already encodes the filter server-side. The consumer routes the URL through the matchingio/*element in the circle (LinkedIn forlinkedin.comURLs); the URL is the filter. When bothsource_urland structured rules are populated, URL-sourced leads are post-qualified against the rules before pursuits open.
Evaluation is on_demand — the ruleset runs at consumer-op invocation time, not
per-request. Multiple ICPs can coexist per circle (e.g. one for enterprise, one
for SMB, one for a campaign); the consumer selects which ICP to use by reference.
Element Definition
- Type:
icp· Category: modifiers · Form: modifier (modifier_type: config) - Evaluation:
on_demand(no middleware phase) - Applies to:
*(attachable to any element that wants to reference match rules; the primary consumer issales-board)
Properties
| Property | Type | Default | Notes |
|---|---|---|---|
match_mode | string (all | any) | all | How populated rules combine — AND vs OR. |
industries | array of string | [] | Target industries (SNI codes or labels like SaaS); substring-insensitive match. |
regions | array of string | [] | Geographic regions; substring-insensitive match. |
min_employees | integer (≥ 0) | — | Minimum headcount, inclusive. |
max_employees | integer (≥ 0) | — | Maximum headcount, inclusive. |
min_revenue_cents | integer (≥ 0) | — | Minimum annual revenue in minor units (cents/öre). |
max_revenue_cents | integer (≥ 0) | — | Maximum annual revenue in minor units. |
required_signals | array of string | [] | Buying signals that must be present (hiring, funding_event, …). |
excluded_keywords | array of string | [] | Disqualifier strings; any match excludes the lead. |
target_roles | array of string | [] | Decision-maker roles; substring-insensitive. Empty matches company-only leads. |
min_score | integer (0–100) | 0 | Minimum agent-computed fit score the lead must carry. |
notes | string (textarea) | — | Free-form description of who this ICP is for. |
source_url | string (uri) | — | Saved LinkedIn search URL; presence switches the ICP to URL sourcing mode. |
No property is required (required: []).
Ports
None defined.
Capabilities
| Capability | Description |
|---|---|
lead-matching | Evaluate a lead against the ICP ruleset. |
prospect-scan | Filter a lead list down to ICP matches. |
Error Codes
| Code | Class | Retryable | Meaning |
|---|---|---|---|
ICP_CONFIG_INVALID | validation | no | ICP configuration has conflicting or malformed rules. |
ICP_NO_RULES | validation | no | ICP has no populated rules — every lead would match trivially. |
Operations
ICP exposes no element-specific operations (operations: {}). It is a pure
config element, following the precedent set by modifier/variable. Inspect an
ICP via the generic element GET endpoint, which returns the element’s spec
(including the full rule set). The matching logic lives on the consumer side
(e.g. sales-board.ingest-icp-matches), which reads ICP.spec by reference.
Quick Start
Create an ICP with structured rules (no element-specific invoke op — you configure its spec, then a consumer op references it):
# Create an enterprise-fintech ICP in the circle
POST /api/{circle}/enterprise-fintech-icp
{
"spec": {
"match_mode": "all",
"industries": ["fintech", "SaaS"],
"regions": ["Sweden", "Norway"],
"min_employees": 50,
"target_roles": ["VP Sales", "CTO"],
"min_score": 70,
"notes": "Nordic enterprise fintech buyers"
}
}
# Inspect the ICP (returns the full rule set in its spec)
GET /api/{circle}/enterprise-fintech-icp
URL sourcing mode — paste a saved LinkedIn search URL instead:
POST /api/{circle}/linkedin-sourced-icp
{
"spec": {
"source_url": "https://www.linkedin.com/search/results/people/?keywords=...",
"min_score": 60
}
}
The ICP is then consumed by a sales-board op (ingest-icp-matches), which reads
these rules by reference, sources/filters leads, and opens pursuits for matches.
Common Mistakes
- Empty ICP matches everything. With no
source_urland no structured rules populated, the ICP has nothing to match or source against — a validation warning fires (and conceptually maps toICP_NO_RULES). Populate at least one rule or asource_url. - Inverted bands. If both
min_employeesandmax_employeesare set,minmust be ≤max; likewise formin_revenue_cents/max_revenue_cents. Inverted bands fail validation (min_employees must be ≤ max_employees). - Non-LinkedIn
source_url. LinkedIn is currently the only route that completes end-to-end. Apollo, allabolag and other sources activate as thoseio/*integrations ship; pasting a non-LinkedIn URL fails at ingest time. - Revenue in major units.
*_revenue_centsare minor units (cents/öre), not whole currency —50_000_00is 50,000, not 5,000,000.
Relationships
- Attaches to: circle
Capabilities
- lead-matching: Evaluate a lead against the ICP ruleset
- prospect-scan: Filter a lead list down to ICP matches
Properties
| Property | Type | Default | Description |
|---|---|---|---|
match_mode | string | "all" | How populated fields combine. ‘all’ = AND (every rule must match), ‘any’ = OR (at least one rule matches). ‘all’ is typical for ICP; ‘any’ is useful for exploratory scans. |
industries | array | [] | Target industries. Free-form strings — typically SNI codes (Swedish industry classification) like ‘62.01’ for software development, but can be arbitrary labels (‘SaaS’, ‘fintech’, ‘healthtech’). Match is substring-insensitive. |
regions | array | [] | Geographic regions. Countries, counties, cities — whatever granularity your research agent reports. Match is substring-insensitive. |
min_employees | integer | — | Minimum headcount (inclusive). Omit for no lower bound. |
max_employees | integer | — | Maximum headcount (inclusive). Omit for no upper bound. |
min_revenue_cents | integer | — | Minimum annual revenue in minor units (cents/öre). Currency is implicit per-region (use a second ICP with a converted band if mixing currencies). |
max_revenue_cents | integer | — | Maximum annual revenue in minor units. |
required_signals | array | [] | Buying signals that must be present on the lead for a match. Free-form strings matched against lead.enrichment.signals (if the research agent populated them): ‘hiring’, ‘funding_event’, ‘news_mention’, ‘procurement_posting’, ‘technology_stack_match’, etc. |
excluded_keywords | array | [] | Disqualifier strings. If any appears in the lead’s company, name, or enrichment.notes, the lead is excluded regardless of other matches. Use for known bad fits (‘student’, ‘sole trader’, defunct brands). |
target_roles | array | [] | Decision-maker roles to target. When specified, leads whose role field doesn’t match any entry are excluded. Match is substring-insensitive (‘VP Sales’ matches ‘VP of Sales’). Leave empty to match company-only leads (no person attached yet). |
min_score | integer | 0 | Minimum agent-computed fit score (0-100) the lead must carry. Filters out low-quality research output before it reaches the pipeline. |
notes | string | — | Free-form description of the target — who this ICP is for, why it exists. |
source_url | string | — | A saved LinkedIn search URL that already encodes the target filter. (Apollo, allabolag and other sources will activate when those integrations ship; for now LinkedIn is the only route that completes end-to-end — pasting a non-LinkedIn URL will fail at ingest time with a clear “add an io/… element” message.) When set, the ICP’s sourcing phase fetches leads via the matching io/* element in the circle (io/linkedin for linkedin.com URLs). The structured firmographic/behavioral rules below still run as a post-qualification filter if populated — leave them empty to accept every sourced lead. |
Operations
attach
Post /ops/attach | Auth: Read
Attach this modifier to a target element
Attaches this modifier to a target element. The target_id must be a UUID of an existing element that supports this modifier type (check applies_to in definition.yaml). Priority controls evaluation order when multiple modifiers of the same type are attached — lower priority runs first. The attachment is stored in element_modifiers table. Cascade resolution runs at bond-time to merge this modifier into the target’s resolved config. Common mistake: attaching to an incompatible element type — check topology rules first.
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.
detach
Post /ops/detach | Auth: Read
Detach this modifier from a target element
Removes this modifier from a target element. Requires the target_id. Pervasive modifiers (audit, policy) can only be detached at the level they were originally attached — inherited pervasive modifiers cannot be detached by child elements. After detach, cascade resolution re-runs to remove this modifier’s effect from the resolved config.
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.
evaluate
Post /ops/evaluate | Auth: Read
Evaluate modifier against current context
Evaluates this modifier against a context and optional target_id. Returns applies (bool), result (modifier-specific), and message. For modifiers without custom evaluation logic, returns a default pass result. Auth-policy returns allowed/denied. This is the explicit evaluation endpoint — during normal request flow, modifiers are evaluated automatically by the cascade resolver as middleware.
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_attached_modifiers
Get /ops/attached/{target_id} | Auth: Read
Get all modifiers attached to a target element
Lists all modifiers attached to a specific target element, including modifier_id, type, subcategory, and priority. Useful for debugging cascade resolution or understanding which policies apply to an element before invoking it.
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_attachments
Get /ops/targets | Auth: Read
List all elements this modifier is attached to
Returns all target elements where this modifier is currently applied. Shows target_id, target_type, priority, and cascade_policy.
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.
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.
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.
Error Codes
| Code | Class | Retryable | Description |
|---|---|---|---|
ICP_CONFIG_INVALID | validation | no | ICP configuration has conflicting or malformed rules |
ICP_NO_RULES | validation | no | ICP has no populated rules — every lead would match trivially |
Lifecycle / runtime
Inherited from modifiers
Execution model: async
Observability
Defined for this element
Metrics
- evaluation_count
- match_count
- reject_count
Events
- icp.evaluated
- icp.matched
- icp.rejected
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
- LinkedIn search URLstring
- Paste a LinkedIn search URL (Apollo, allabolag and others come later). If set, this alone defines the target — leave empty to use the structured rules below.
- Who is this ICP for?string
- Free-form description — who this ICP targets and why it exists
- Match modestring
- 'all' (AND) = every populated rule must match. 'any' (OR) = at least one must match.
- Industriesstring
- Target industry codes or labels (SNI, SaaS, fintech, etc.)
- Regionsstring
- Geographic targets — countries, counties, cities
- Min employeesstring
- Minimum headcount
- Max employeesstring
- Maximum headcount
- Min revenue (cents)string
- Minimum annual revenue in minor units
- Max revenue (cents)string
- Maximum annual revenue in minor units
- Required signalsstring
- Buying signals the lead must carry (hiring, funding_event, etc.)
- Target rolesstring
- Decision-maker roles — VP Sales, CTO, Head of X
- Excluded keywordsstring
- Disqualifier strings — lead is excluded if any appear
- Min fit scorestring
- Minimum agent-computed score (0-100)