Account Research Report
What this skill does
The Onfire MCP owns the data pipeline. This skill owns the rendering, and
owns the two derivations the orchestrator does lossily: the tenant's use-case
taxonomy and its vendor/persona resolution (Step 1a).
Given a company website (e.g. meridianbank.com) and a tenant ID
(e.g. ironwall), this skill:
- Calls
account_research for the non-prospect data sources: tenant
config + derived use cases, 10-K extracts, LinkedIn footprint, intent
signals, and the inline render_spec that defines the rendering
contract.
- Re-derives the tenant's taxonomy (Step 1a). The envelope's
derived_use_cases and footprint_keywords are coarser than the tenant's
own configuration and their resolution is not match-quality gated, so the
skill resolves the tenant's personas and vendor list itself, behind a
match-tier gate.
- Takes the prospect set from the envelope when it is already complete, and
only calls
ai_prospecting directly to poll a run that is still going.
- Enriches the report with warehouse signals the orchestrator does not
pre-pull - hiring momentum, active hiring managers, growth direction,
the real competitor/technology footprint, and golden-persona contacts -
via a fixed set of structured
ask_onfire queries (Step 1d). These feed
the Why-Now, Confirmed-deployment, and Key-contacts sections.
- Builds the company card from warehouse firmographics when the account is
not an SEC filer (Step 1e), instead of narrating unsourced figures.
- Enforces the rendering contract on a self-contained A4 HTML file.
- Runs the pre-delivery checklist before delivery.
- Handles follow-up questions by slicing the datasets already produced, or
by calling
ask_onfire / one of the narrow typed tools when the user asks
for genuinely new data.
The skill never writes raw SQL and never touches Snowflake or the
signals database directly. All data plumbing lives inside the Onfire
MCP. ask_onfire is part of that MCP surface: it takes a structured
query (a QueryIR of entity + filters, never SQL), validates it against
the semantic model server-side, and never exposes schema, table names,
or vendors — so authoring ask_onfire queries is consistent with that
principle, not an exception to it.
Inputs
| Input |
Required |
Example |
company_website |
Yes |
meridianbank.com |
tenant_id |
Yes |
ironwall |
company_linkedin_url |
Optional |
https://www.linkedin.com/company/meridian-bank/ |
Step 1 - Call the orchestrator for non-prospect data
Onfire MCP: account_research(
company_website="<company_website>",
tenant_id="<tenant_id>",
company_linkedin_url="<url>", # optional but enables footprint
telemetry={intent: "Account research report for tenant <tenant_id>"}
)
The filings_10k, linkedin_footprint, and intent_signals blocks are
always complete on the first call. The prospects block is the output of the
orchestrator's own ai_prospecting run - read it (Step 1b) rather than
re-running prospecting from scratch.
Fire ai_prospecting_field_glossary (Step 1c) concurrently with this call. It
has no dependency on the envelope, and waiting for prospects before loading it
adds a round trip for nothing.
Step 1a - Resolve the tenant's real taxonomy and vendor list (REQUIRED)
tenant_config.derived_use_cases and tenant_config.footprint_keywords arrive
pre-computed, but both are coarser than what the tenant's own configuration
supports:
- The use-case set is a generic security-category mapping. It does not always
contain the category the tenant actually sells, and a card can be produced by
a loose keyword hit rather than real evidence.
- The keyword list is resolved without a match-quality gate and is truncated, so
it can under-cover a tenant's competitors while over-matching on broad
infrastructure terms. Nothing in the envelope flags either case.
Neither is a reason to distrust the envelope's data blocks - they are the reason
to derive the taxonomy yourself, in two calls:
get_tenant_settings(tenant_id) - returns the full
account_research.queries_sections (competitors, organization,
technologies, cloud_providers), plus golden_persona and
display_names_mapping.
resolve_insights(concepts=[...], kind="technology") - ONE call carrying
every competitors + technologies value, with the trailing " Insight"
suffix stripped. Tenant config stores vendors with that suffix and it is
not part of any catalog name, so leaving it on drops every concept to the
partial tier - where the top candidate may be the wrong kind entirely (a
suffixed Datadog ranks the persona data first), too broad, or right but
unverified. Stripped, the same vendors resolve match: "exact". See the
worked before/after tables in references/ask-onfire-signals.md.
Then resolve the personas the same way with kind="persona": every
queries_sections.organization value plus golden_persona.
Match-tier gate (non-negotiable). Every candidate carries a match tier.
Keep exact and synonym. Treat partial and fuzzy as unresolved and never
search on them. Do not try to judge a partial candidate by whether its value
looks plausible - some are right and some are a persona wearing a technology's
name; the tier is the signal, not the value. Never render the unresolved list in
the customer report; it is an internal detail.
Use cases: prefer the tenant's own taxonomy
Build the use-case set from the resolved organization personas, labelled via
display_names_mapping (the tenant already ships human labels, e.g.
email_security -> "Email Security Specialist"). Keep 3-5, ordered by the
evidence you actually found for this account in Step 1d.
This deliberately overrides tenant_config.derived_use_cases. When you do:
- Colors come only from
render_spec.use_case_palette. Assign an existing
palette entry per card; never invent a hex, never emit a tag class that is not
a palette key.
- Never render a card whose only evidence is a single partial keyword match.
- Every card must position the tenant on the product it actually sells. If a
card has no honest tie to that product, drop the card - do not manufacture an
angle.
- Fall back to the orchestrator's
derived_use_cases when
get_tenant_settings errors or the tenant has no organization list.
Step 1b - Prospect set: read the envelope first, poll only if needed
The orchestrator already runs ai_prospecting(action="run", use_cache=True)
inside its own fan-out, so the envelope's prospects block is that tool's
output, not a lesser copy of it.
envelope.prospects.status == "completed" -> use it as-is. Do not re-call
ai_prospecting; that spends another round trip of up to 50s for rows you
already hold.
still_running, skipped, or an error -> poll with
ai_prospecting(action="run", company_linkedin_url="<url>"), re-calling with
the returned run_ids (or identical arguments) until status="completed".
Phoenix dedups server-side, so this joins the in-flight run instead of
starting a second one.
- Prospecting needs the company LinkedIn URL. Resolve it with the
match-company skill first when the envelope has none.
Render every prospect from whichever response completed - both the inline
prospects array and the preview-shape top_picks + preview_rows. Treat its
dataset.id as the authoritative prospect dataset for query_datasets slicing
and for download_dataset.
When prospecting_enabled is false, or the completed response carries zero
prospects, do not drop Section 8. Fill it from the Step 1d contact sources:
active hiring managers and golden-persona contacts, each labelled with where it
came from.
Step 1c - Load the prospecting field glossary (REQUIRED when prospects are present)
Fetch this concurrently with Step 1, not after prospects land - it has no
dependency on either.
The ai_prospecting response carries fields whose meaning is non-obvious
and easy to invert (e.g. MASTER_SCORE_PRIORITY is a tier where lower
is better; SCORE_WARM_INTRO is an enum -- PLATINUM > GOLD > SILVER > COLD -- not a number). Misinterpreting these silently
produces wrong reports. Before rendering any prospect, call:
Onfire MCP: ai_prospecting_field_glossary()
This returns a self-describing contract for every prospect field:
type, values (enum or bounded range), what_it_means, how_to_use,
and examples. Use it as the authoritative source for:
- What "good" looks like on every score (
COMPOSITE_SCORE is
bounded 0-1500; >800 is top-decile, <400 is a stretch).
- Tier direction --
MASTER_SCORE_PRIORITY=1 is the actionable
cohort, not tier 5.
- Warm-intro enum ordering -- PLATINUM (alumni at target) is the
highest-leverage path; COLD requires cold outbound.
- Boolean signals --
WORKED_IN_CLIENT_COMPANY_IN_PAST=true is
the alumni flag, the highest-value expansion play.
- Which fields are ready-made copy --
product_talking_points and
ai_reasoning are pre-written outreach payload; never rewrite, just
surface verbatim.
The ai_prospecting response also carries:
field_glossary_resource_uri - the MCP resource URI for the same
glossary. Clients that auto-inject resources will load it without
an explicit call; on other clients fall back to the tool.
field_index - the sorted list of every field name as a fast
schema-drift check. If a field in top_picks is missing from
field_index, treat it as unverified and skip rendering it rather
than guessing.
When ai_prospecting returned zero prospects, do not call the
glossary -- there's nothing to interpret yet.
Step 1d - Enrich with ask_onfire warehouse signals (REQUIRED)
The orchestrator pulls four blocks (filings, footprint, intent signals,
prospects). Several signal surfaces that materially decide whether this report
is worth reading are not in the envelope. Pull them, scoped to the account
by its LinkedIn URL.
This step used to be optional and self-selected, which is why two runs of the
same account produced different reports. The mandatory set below is now fixed.
ask_onfire takes a structured QueryIR (entity + filters + insight_filters +
limit), validates it against the semantic model, and returns rows. Read
references/ask-onfire-signals.md for the exact per-entity recipes. Never
guess field names and never write SQL.
Schema lookups: one call, not one per entity
describe_onfire_schema accepts a list. When you need to confirm fields,
make a single describe_onfire_schema(["job_post", "hiring_manager_signal", ...]) call rather than one per entity.
Mandatory pulls
| Pull |
Entity |
Feeds |
| Open roles / hiring momentum |
job_post |
Why-Now (Section 3), use-case account-signals |
| Active hiring managers (decision-makers) |
hiring_manager_signal |
Key contacts (Section 8), Why-Now |
| Growth direction |
growth_insight_monthly or headcount_monthly |
Why-Now, company card |
| Competitor / technology footprint (the Step 1a resolved list) |
contact + insight_filters kind=technology |
Confirmed deployment (Section 4) |
| Golden-persona contacts |
contact + insight_filters kind=persona |
Key contacts (Section 8), use-case cards |
The last two exist because the orchestrator's own footprint searched a truncated,
badly resolved keyword list. Yours searches the vendors the tenant actually
competes with and the persona it actually sells to.
Optional pulls - only when a section is still thin
event_company / event_contact (event presence), insight_evidence (dated
"in production since" proof), product_adoption (incumbent adoption quarter and
likely renewal quarter - BETA, both dates are estimates, so any figure from
it must be labelled as an estimate), github_member (developer engagement),
people_experiences (alumni / warm-path context).
Two mechanics that matter
- OR in a single call. An
insight_filters entry accepts a list as its
value, so the whole competitor set is one query, not one per vendor:
insight_filters: [{kind: "technology", value: ["CrowdStrike", "SentinelOne", "Microsoft Defender"]}]. Separate entries AND together; a
list inside one entry ORs.
- Always set an explicit
limit. A direct ask_onfire call returns rows
per the limit you set; 5-10 is plenty for report evidence, 25-30 for the
competitor footprint. If the response is needs_confirmation
(stage: "row_budget") it returned no rows - tighten a filter or lower
limit and resubmit. Do not blindly set confirmed: true.
Requires company_linkedin_url (reuse company.linkedin_url from the envelope,
or resolve via the match-company skill). Without it, skip this step and render
from the orchestrator blocks alone.
Step 1e - Company profile when the account is not an SEC filer
filings_10k.found = false is the common case, not the exception - non-US and
privately held companies have no 10-K. When it is false, do not narrate
figures you cannot source. Build the company card from:
ask_onfire on company for firmographics (industry, HQ, size band, type),
get_company_headcount for current headcount,
- the Step 1d
headcount_monthly pull for direction,
search_offices when geographic footprint is relevant.
Any figure that does not come from one of those must carry its source inline in
the card, or be left out. An empty stat is better than an uncited one.
Response shape (the envelope you render from)
{
"status": "completed" | "still_running",
"company": { website, linkedin_url, name, ticker, latest_filing_date, ... },
"tenant_config": {
"golden_persona", "prospecting_enabled",
"derived_use_cases": [{id, label, tag, evidence_count, ...}],
"excluded_use_cases": [...],
"footprint_keywords": [...]
},
"filings_10k": { found, filings: [{sections: {...}, keyword_hits: [...]}],
dataset: { id, ... } },
"linkedin_footprint": {
// INSIGHT-BASED: people at the company who CARRY the tenant's
// technology insights (the orchestrator resolves the tenant's
// tech/competitor keywords to canonical technology insight_names
// and pulls active employees carrying each, via the semantic layer).
dataset, preview_rows, top_profiles,
"facets": { "by_keyword": { /* keyed by resolved insight name */ } },
"resolved_technologies": [ /* canonical insight names searched */ ],
"unresolved_keywords": [ /* tenant keywords not in the catalog */ ],
"total_matched": 0 // present only when more matched than returned
// each top_profiles / preview row carries: matched_keyword (the
// resolved technology insight), matched_insights (list), evidence_term,
// and evidence_sentence (BEST-EFFORT — may be null; see Section 4)
},
"intent_signals": { dataset, preview_rows, facets, total_count },
"prospects": { /* the orchestrator's own ai_prospecting run - see Step 1b */ },
"datasets": { filings_10k, linkedin_footprint, intent_signals, prospects },
"render_spec": {
"section_order": [...],
"hard_rules": [...],
"use_case_palette": {...},
"page_setup": {...},
"pre_delivery_checklist": [...],
"follow_up_tools": {...}
}
}
Use the inline render_spec (and the fixed Onfire palette)
The orchestrator ships the rendering contract inline. Do not invent your
own section order, palette, or rules. Read each from render_spec:
render_spec.section_order - the canonical section order
render_spec.hard_rules - every constraint you must apply
render_spec.use_case_palette - the only colors allowed for use case tags
render_spec.page_setup - A4 dimensions, font stack, print-color-adjust CSS
render_spec.pre_delivery_checklist - the server's baseline checks; Step 3
runs those plus the skill's own (see Step 3)
The report's color palette is Onfire's, not the tenant's. The
--brand (navy) / --accent (purple) tokens are hard-coded in the CSS
block in references/report-structure.md and do not vary per tenant.
Ignore tenant_config.brand.primary if present — it's legacy. The only
tenant-driven content in the header bar is the display name and logo:
| Render value |
Source |
Fallback when absent |
| Tenant display name (header + footer) |
tenant_config.tenant_id, title-cased |
always apply — no display_name field exists |
| Tenant logo (header + footer) |
not available in tenant config |
always omit logo; render text wordmark only |
If render_spec is missing or empty (older orchestrator version), use
the defaults documented in references/report-structure.md as a fallback,
but always prefer the inline contract.
Step 2 - Render the report
Read references/report-structure.md for the full A4 HTML template and
component snippets.
Section order (from render_spec.section_order)
- Header bar - brand-colored full-width bar with tenant logo
(base64, when provided), brand display name, "Account Research -
[Company]" eyebrow, and date.
- Company header card - name (LinkedIn link), ticker, HQ, stat
grid, overview. When
filings_10k.found is false, build the stat grid
from the Step 1e warehouse pulls; every figure carries a source or is
dropped. Never narrate an uncited financial number.
- Why this account - why now - 3-5 points sourced from
filings_10k.filings[].sections, intent_signals.preview_rows,
the footprint dataset, and the Step 1d ask_onfire
enrichment — open-role surges (job_post, with date_posted),
active hiring managers (hiring_manager_signal, with signal_date),
event presence (event_company attendee counts, with the event
year), and rising adoption (growth_insight_monthly /
headcount_monthly, citing the month + growth direction). Every
point carries a parenthetical date or "current role" citation. Render
either as numbered prose rows or as a severity-tinted alert stack
(see references/report-structure.md Section 3 Style A vs B).
- Confirmed technology deployment - render from the Step 1d
competitor/technology footprint pull, plus the orchestrator's
footprint dataset (not its
top_profiles).
top_profiles is a small inline preview, not the result set - it
commonly carries 3 rows where the account matched dozens of people. The
dataset holds the full pull; slice it instead. It typically yields 20-30
evidence-backed rows:query_datasets(
datasets={"footprint": "<envelope.datasets.linkedin_footprint>"},
sql="SELECT full_name, linkedin_url, job_title, location_name,
matched_keyword, evidence_sentence
FROM footprint WHERE evidence_sentence IS NOT NULL"
)
Dataset slicing is free, so there is no reason to render only 3.
Confirm only what resolved cleanly. A confirmation label may name a
vendor only if it came back exact or synonym from Step 1a. If a row's
matched_keyword is not a product name - a hardware architecture, a
networking or infrastructure category, an analysis-technique acronym, or a
vendor's parent brand where the config named a specific product - it is not
a deployment confirmation. Drop the row instead of claiming a vendor is in
place. Test: could you say "the account runs " to their
CISO without it sounding wrong? If not, drop it.
Each person genuinely carries the insight, and matched_keyword is the
canonical technology name (use it for the confirmation label, e.g.
"CrowdStrike confirmed"). evidence_sentence is best-effort and may be
null (the insight tag does not require the literal term in the bio):
- When present, quote it verbatim in the evidence block (same
rule as before).
- When null, render the confirmation from the matched technology
without a fabricated quote — state the person carries the
deployment signal; do NOT invent a sentence.
Optionally strengthen an entry with a Step 1d
insight_evidence pull
to add a "in production since [start_date]" date. Skip the whole
section only when both the Step 1d pull and the dataset are empty.
- Intent signals - render
intent_signals.preview_rows with each
signal's message_text quoted verbatim in a grey evidence block.
See "Quote, never rewrite" below. Omit the entire section when
there are zero signals - do not render a negative-state placeholder.
This block is scoped by an exact account_website match and is
genuinely sparse (commonly 0-2 rows per account), so a thin or absent
section here is normal. Do not compensate by promoting Step 1d
enrichment into it and labelling it an intent signal - hiring activity
is hiring activity. If the account may be filed under a sibling domain,
one extra query_intent_signals call with that domain is worthwhile.
Some rows are prior-relationship signals - a person the tenant sold to
or worked with at a former customer, now at this account. Slice them
with WHERE signal_type IN ('Champion Moved','Contact Moved','Champion Move','Ex-Customer Contact Move','Ex-Customer Hire'), filtering on
signal_type and never source_name. Tag the Champion types "Former
champion" and the rest "Known contact" - never a contact as a champion -
and show the move date, which is often years old.
- Solution fit divider + section head - hairline divider followed
by a single eyebrow line "Solution fit - [Tenant Display Name] use
cases at [Account Display Name]" introducing the use case cards
(no separate title/subtitle).
- Use case cards - one per entry in the Step 1a derived use-case
set (which overrides
tenant_config.derived_use_cases; see Step 1a
for why), ordered by the evidence actually found for this account.
Keep 3-5. Drop any card whose only evidence is a single partial keyword
match, and any card with no honest tie to what the tenant sells - a
shorter report beats a wrong one. Every card's alignment column
positions the tenant on its own product; never argue an adjacent
security category the tenant does not sell. Each card pulls relevant
signals + verbatim talking-point
quote (10-K, LinkedIn profile, public talk, or any other verifiable
source - see report-structure.md "Talking-points source citation")
- prospect rows that map to that use case. The right column is
brand-named: render its label as "[Tenant Display Name] solution
alignment" (e.g. "Artifex solution alignment"). Tag colors come from
render_spec.use_case_palette keyed by the use case tag - never
invent a color.
- Key contacts per use case -
break-before: page, color-coded
from render_spec.use_case_palette by the use case tag. Each
contact card must render the fields the
ai_prospecting_field_glossary how_to_use guidance calls out:
warm-intro tier + connector name + shared company, composite score
with breakdown, top three personas from CURRENT_PERSONAS,
PAST_COMPANIES_USED_CLIENT_TECH when non-empty, career-momentum
signals, the ai_reasoning bullets verbatim, and an opener from
product_talking_points. Do not drop these fields silently -
consistency across contact cards matters.
Active hiring managers from the Step 1d hiring_manager_signal
pull are a complementary contact source: a person actively building a
team is a live decision-maker / budget owner. Surface them alongside
the ai_prospecting contacts (tag them "actively hiring -
[job_post_title]"), mapping each to its use case via the role being
hired for.
Golden-persona contacts from the Step 1d persona pull are the second
complementary source: people at the account carrying the tenant's
golden_persona are its literal buying persona. Tag them with the
persona's display_names_mapping label.
When prospecting is disabled or returns zero, these two sources carry
Section 8 on their own. Label each contact with the source it came from,
and never print a bare "no prospect list available this cycle" line as
the section's only content - if all three sources are empty, omit the
section.
When surfacing prospect rows in sections 7 and 8, interpret every
field through the ai_prospecting_field_glossary contract loaded
in Step 1c - never invent score semantics.
Hard rules (from render_spec.hard_rules - non-negotiable)
- No em dashes anywhere outside verbatim evidence quotes. Use a
regular hyphen
-.
- No internal tool names anywhere in the HTML. Never write Metabase,
Snowflake, Phoenix, Onfire, MCP. Use: "market intelligence", "intent
signals", "public filings", "industry research".
- Signal messages quoted verbatim - never paraphrase or reframe.
Trim with leading/trailing ellipsis only.
- Company name is a LinkedIn link -
<a href="[linkedin]"> with a
1.5pt dotted underline in var(--faint).
- Brand colors are fixed Onfire tokens -
var(--brand) (navy
#0A2540) and var(--accent) (purple #7C5CFF) are hard-coded in
the CSS block. Do not hardcode hex literals; do not pull
tenant_config.brand.primary to override them. The report is
Onfire-branded; tenant brand surfaces only as text/logo content in
the header bar.
- Footer - company name, Account Research, [Month Year]. Nothing else.
- No buying committee or cold opens section.
- System fonts only - no Google Fonts CDN (file:// blocks it).
Evidence block - quote, never rewrite (CRITICAL)
For every signal with a non-empty message_text, render that message
in a grey evidence block as a verbatim excerpt. You MAY trim with
leading/trailing ellipses (...) to focus on the relevant span, but you
MUST NOT paraphrase, summarize, translate, fix typos, reflow whitespace,
or otherwise alter the characters inside the quoted span. The text
inside the quote must be a contiguous substring of message_text
byte-for-byte. Never substitute short_summary or any other column
- for Company Change and Promotion signals as well, the evidence block
is
message_text or nothing. If message_text is empty or null,
render (no message text on record) or skip the evidence block; do
not fabricate or substitute another field.
The same rule applies to every evidence_sentence - from top_profiles,
from the sliced footprint dataset, or from a Step 1d pull.
Page setup (from render_spec.page_setup)
@page { size: A4; margin: 16mm 18mm 18mm 18mm }
body { width: 174mm }
- Font stack:
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif
- All font sizes in
pt: body 9pt, labels 7pt, headings 11-15pt
print-color-adjust: exact rule in @media print (preserves background colors when printing)
- Every
.card has break-inside: avoid
- Key contacts sections have
break-before: page
Use case palette (from render_spec.use_case_palette)
Only use the tag classes that appear in the palette. The palette is the
colour contract; Step 1a owns the card set. So: render the Step 1a
use cases, and assign each one an existing use_case_palette entry.
- Never invent a colour and never emit a tag class that is not a palette key.
- Reuse a palette entry whose semantics are closest to the card, or simply
assign entries in order. The palette has 7 slots; keep to 3-5 cards.
- Never assume a fixed list of use cases (no hardcoded "four canonical
use cases").
- If a card has no sensible palette entry, fall back to the neutral
--low-bg / --low-text tokens rather than guessing a hex.
Step 3 - Pre-delivery checklist (from render_spec.pre_delivery_checklist)
Before saving the final HTML and calling present_files, run every check
below. All must pass.
Use case tags constrained to the palette
grep -oE 'class="tag" style="background:var\\(--[a-z]+-bg' report.html
Every tag class must be one of the palette keys in
render_spec.use_case_palette. No invented tags.
No internal tool names, no em dashes outside verbatim quotes
One pass does both:
grep -niE 'phoenix|metabase|mcp|onfire|—' report.html
Zero matches, except a U+2014 inside a class="evidence" /
class="quote" block (those preserve message_text byte-for-byte).
Why Now evidence references
Every <div class="why-row"> body must contain a parenthetical in
its bold strong tag - (... [date] / [date range] / "current role")
- with one of the acceptable source types (10-K, LinkedIn profile,
conference, community Slack/Discord, LinkedIn post, company-change
records). No date-less Why Now points.
Prospect field interpretation
If the ai_prospecting response from Step 1b carries real rows
(not still_running / zero-result), confirm
ai_prospecting_field_glossary was loaded and every
prospect-derived rendering decision (tier label, warm-intro
wording, score commentary) traces to a what_it_means /
how_to_use entry in the glossary. If you cannot point to the
glossary entry that justifies a phrase, remove the phrase.
Prospect source provenance
Every prospect must come from a completed ai_prospecting response -
either the envelope's block (Step 1b, preferred) or the standalone poll.
Never render rows from a still_running response.
Vendor confirmations traced to a clean resolution
Every "X confirmed" label in the Confirmed-deployment section names a
vendor that resolved exact or synonym in Step 1a, and the label names a
product - never a hardware architecture, an infrastructure category, an
analysis-technique acronym, or a parent brand standing in for a specific
product. If you cannot point at the resolution that justifies a label,
remove the row.
Deployment section not needlessly truncated
If the footprint dataset holds more evidence-backed rows than the report
renders, you rendered the inline preview instead of slicing the dataset.
Go back to Section 4.
Use cases are the tenant's own, and honestly positioned
Each card traces to a Step 1a persona (or to the documented fallback),
no card rests on a single partial keyword match, and no card argues a
product category the tenant does not sell.
If any check fails, fix the report and rerun all checks. Do not
deliver until all pass.
Step 4 - Output
A4 HTML file
Generate a fully self-contained HTML file:
- Tenant logo embedded as base64 data URI - no external image references
- System font stack only - no Google Fonts CDN
print-color-adjust: exact CSS in @media print
- Save to
/mnt/user-data/outputs/account-research-<company>-<tenant>.html
- Call
present_files
PDF instructions for user
Tell the user:
"To convert to PDF: open in Chrome -> Cmd/Ctrl+P -> Save as PDF -> enable Background graphics -> Save."
Handling follow-up questions
The orchestrator ships three dataset IDs in envelope.datasets
(filings_10k, linkedin_footprint, intent_signals). The
ai_prospecting call from Step 1b ships the fourth — the prospects
dataset — on its own response (dataset.id). Every slicing question
reuses those datasets via query_datasets - no re-orchestration, no
new SQL.
Slice already-pulled data (zero-cost follow-ups)
For questions like "break down signals by source", "show me only SecureCon
attendees", "give me all the prospects, not just the top 10":
query_datasets(
dataset_id="<envelope.datasets.intent_signals | envelope.datasets.filings_10k
| envelope.datasets.linkedin_footprint
| ai_prospecting_response.dataset.id>",
sql="SELECT ... FROM dataset WHERE ..."
)
Common patterns:
- Signal source mix:
SELECT source_name, COUNT(*) FROM dataset GROUP BY 1
- Filter signals by event:
WHERE source_name = 'SecureCon 2026'
- Prior-relationship signals:
WHERE signal_type IN ('Champion Moved', 'Contact Moved', 'Champion Move', 'Ex-Customer Contact Move', 'Ex-Customer Hire')
- Filter prospects by team:
WHERE LOWER(TITLE_NAME) LIKE '%cloud%'
- Pull a specific 10-K paragraph:
SELECT FULL_MARKDOWN FROM dataset then
substring locally.
Pull truly new data (Layer 3 typed tools)
When the user asks for data the orchestrator didn't pull, call the
relevant narrow typed tool. Never write raw SQL.
| User asks for |
Call |
| Signals on a topic outside the tenant's keyword set (e.g. NIS2, DORA) |
query_intent_signals(tenant_id, account_website, keyword_match=[...]) |
| A 10-K section the report didn't surface (e.g. a specific exec name) |
query_company_filings(website, keywords=[...]) |
| Employees carrying a different product / competitor |
ask_onfire — entity=contact, filter current_company_url eq <url>, insight_filters=[{kind:technology, value:[<product>, ...]}] — a list ORs in one call (NOT a raw JOB_SUMMARY ILIKE) |
| People in a given role / persona at the account |
ask_onfire — entity=contact, filter current_company_url eq <url>, insight_filters=[{kind:persona, value:<resolved persona>}] |
| When the incumbent was adopted / when they renew |
ask_onfire — entity=product_adoption, filter company_linkedin_url eq <url> (BETA - estimates, label them) |
| Firmographics for a company with no 10-K |
ask_onfire — entity=company, filter linkedin_url eq <url>, plus get_company_headcount |
| More employee-footprint rows than the report showed |
query_datasets on envelope.datasets.linkedin_footprint — free, no row budget |
| Open roles / what the company is hiring for |
ask_onfire — entity=job_post, filter company_url eq <url> (+ job_function/seniority/open) |
| Who is actively hiring (decision-makers) |
ask_onfire — entity=hiring_manager_signal, filter company_url eq <url> (+ person_seniority) |
| Who attended an event / company event presence |
ask_onfire — entity=event_contact (who) or event_company (counts), filter event eq <resolved> + company_url eq <url> |
| Is a persona/tech adoption growing at the account |
ask_onfire — entity=growth_insight_monthly, filter company_url eq <url> + insight eq <resolved>, order by month |
| Headcount growth trend |
ask_onfire — entity=headcount_monthly, filter company_url eq <url>, order by month |
| Since-when / proof behind a signal |
ask_onfire — entity=insight_evidence, filter company_url eq <url> + insight_value eq <resolved> |
| Developers engaging with an OSS repo |
ask_onfire — entity=github_member, filter repo_name/activity, join contact |
| Where a person worked before / alumni of the account |
ask_onfire — entity=people_experiences, filter company_url eq <url> + current=false |
See references/ask-onfire-signals.md for the full worked QueryIR of
each recipe, the bound-concept resolution step, and the per-row billing
rule. Each tool/query returns its own dataset, so its output is also
further sliceable via query_datasets.
Error handling
| Situation |
Action |
account_research returns status="still_running" solely because of prospecting |
Use the completed non-prospect blocks and poll prospects per Step 1b. Do not re-call the orchestrator. |
get_tenant_settings fails or the tenant has no organization list |
Fall back to tenant_config.derived_use_cases, and treat the resulting use-case set as generic rather than tenant-specific. |
Every vendor in Step 1a resolves only partial / fuzzy |
Run no technology footprint pull. Render Confirmed deployment from the orchestrator dataset only, and only for rows whose matched_keyword is a real vendor. Never confirm a vendor off a partial match. |
| Step 1d competitor footprint returns zero rows |
Normal for a small or thinly covered account. Fall back to the orchestrator's footprint dataset; if that is empty too, omit the section. |
| Golden-persona pull returns zero rows |
Section 8 falls back to hiring managers, then to prospects. If all are empty, omit Section 8. |
linkedin_footprint.skipped is true |
Skip the "Confirmed deployment" section silently. |
linkedin_footprint returns zero people (total_count 0), or every tenant keyword is in unresolved_keywords |
Fall back to the Step 1d pull; skip the section only if both are empty. Never render unresolved_keywords in the customer report (internal detail). An empty top_profiles alone is NOT an empty footprint - it is only a preview; check total_count and the dataset. |
linkedin_footprint row has a null evidence_sentence |
Render the confirmation from matched_keyword without a quoted evidence block; never fabricate a sentence. |
intent_signals.total_count is 0 |
Omit the Intent signals section entirely (per Section 5). Common and expected - do not backfill it with Step 1d enrichment. |
Step 1d ask_onfire returns needs_confirmation (stage: "row_budget") |
No rows billed. Lower limit to what the section needs and resubmit; do not blindly set confirmed: true. |
Step 1d ask_onfire returns zero rows or an error |
Skip that enrichment silently; render from the orchestrator blocks. Never fail the report. |
ai_prospecting returns status="still_running" |
Re-call with the returned run_ids (or identical args). Phoenix dedups server-side. |
ai_prospecting returns zero prospects (top_picks: []) or tenant_config.prospecting_enabled is false |
Drop the prospect columns in Section 7 cards, but keep Section 8 and fill it from the Step 1d hiring-manager and golden-persona pulls. Omit Section 8 only when all three sources are empty. |
Company has no LinkedIn URL even after match-company |
Skip Steps 1b and 1d entirely; render from company_website-scoped blocks only. Step 1a still runs - the taxonomy is tenant-scoped, not account-scoped. |
filings_10k.found is false |
Expected for non-US and private companies. Run Step 1e and source every company-card figure. |
One of *.error keys is set |
Skip that section; never fail the whole report. |
Reference files
references/report-structure.md - Full HTML template, CSS, layout rules
references/ask-onfire-signals.md - Step 1a resolution gate + Step 1d ask_onfire QueryIR recipes (hiring, events, growth, dated proof, github, alumni, competitor footprint, golden-persona contacts, product adoption, firmographics) + row-budget rules
references/persona-to-usecase.md - Map prospect titles -> use cases for the use-case-cards section
references/pdf-generation.md - PDF conversion instructions
references/use-case-mapping.md - (informational) the keyword-bucket mapping the orchestrator uses server-side; the skill no longer applies this mapping itself
references/10k-extraction.md - (informational) the substring-extraction rules the orchestrator applies server-side; the skill no longer extracts 10-K sections itself
1---2name: account-research3description: Generate a full account research report for any company. The Onfire MCP `account_research` tool fetches the core data sources - tenant config, 10-K extracts, employee technology footprint, intent signals, and AI-scored prospects - in one call and returns a rendering contract alongside the data. This skill enriches it with additional warehouse signals via `ask_onfire` (hiring momentum, event attendance, persona/technology growth trends, dated deployment proof, active hiring managers), enforces the rendering contract, and produces the final customer-facing A4 HTML file. Use whenever a user asks to "generate a report", "research an account", "build a BDR brief", "run account research", or mentions a company domain alongside words like "signals", "prospects", "10-K", "hiring", "events", "growth", "use cases", or "tenant".4---56# Account Research Report78## What this skill does910The Onfire MCP owns the data pipeline. This skill owns the rendering, and11owns the two derivations the orchestrator does lossily: the tenant's use-case12taxonomy and its vendor/persona resolution (Step 1a).1314Given a **company website** (e.g. `meridianbank.com`) and a **tenant ID**15(e.g. `ironwall`), this skill:16171. Calls `account_research` for the non-prospect data sources: tenant18 config + derived use cases, 10-K extracts, LinkedIn footprint, intent19 signals, and the inline `render_spec` that defines the rendering20 contract.212. **Re-derives the tenant's taxonomy** (Step 1a). The envelope's22 `derived_use_cases` and `footprint_keywords` are coarser than the tenant's23 own configuration and their resolution is not match-quality gated, so the24 skill resolves the tenant's personas and vendor list itself, behind a25 match-tier gate.263. Takes the prospect set from the envelope when it is already complete, and27 only calls `ai_prospecting` directly to poll a run that is still going.284. **Enriches the report with warehouse signals the orchestrator does not29 pre-pull** - hiring momentum, active hiring managers, growth direction,30 the real competitor/technology footprint, and golden-persona contacts -31 via a fixed set of structured `ask_onfire` queries (Step 1d). These feed32 the Why-Now, Confirmed-deployment, and Key-contacts sections.335. Builds the company card from warehouse firmographics when the account is34 not an SEC filer (Step 1e), instead of narrating unsourced figures.356. Enforces the rendering contract on a self-contained A4 HTML file.367. Runs the pre-delivery checklist before delivery.378. Handles follow-up questions by slicing the datasets already produced, or38 by calling `ask_onfire` / one of the narrow typed tools when the user asks39 for genuinely new data.4041The skill **never** writes raw SQL and never touches Snowflake or the42signals database directly. All data plumbing lives inside the Onfire43MCP. `ask_onfire` is part of that MCP surface: it takes a structured44query (a `QueryIR` of entity + filters, never SQL), validates it against45the semantic model server-side, and never exposes schema, table names,46or vendors — so authoring `ask_onfire` queries is consistent with that47principle, not an exception to it.4849---5051## Inputs5253| Input | Required | Example |54|-------|----------|---------|55| `company_website` | Yes | `meridianbank.com` |56| `tenant_id` | Yes | `ironwall` |57| `company_linkedin_url` | Optional | `https://www.linkedin.com/company/meridian-bank/` |5859---6061## Step 1 - Call the orchestrator for non-prospect data6263```64Onfire MCP: account_research(65 company_website="<company_website>",66 tenant_id="<tenant_id>",67 company_linkedin_url="<url>", # optional but enables footprint68 telemetry={intent: "Account research report for tenant <tenant_id>"}69)70```7172The `filings_10k`, `linkedin_footprint`, and `intent_signals` blocks are73always complete on the first call. The `prospects` block is the output of the74orchestrator's own `ai_prospecting` run - read it (Step 1b) rather than75re-running prospecting from scratch.7677Fire `ai_prospecting_field_glossary` (Step 1c) concurrently with this call. It78has no dependency on the envelope, and waiting for prospects before loading it79adds a round trip for nothing.808182## Step 1a - Resolve the tenant's real taxonomy and vendor list (REQUIRED)8384`tenant_config.derived_use_cases` and `tenant_config.footprint_keywords` arrive85pre-computed, but both are coarser than what the tenant's own configuration86supports:8788- The use-case set is a generic security-category mapping. It does not always89 contain the category the tenant actually sells, and a card can be produced by90 a loose keyword hit rather than real evidence.91- The keyword list is resolved without a match-quality gate and is truncated, so92 it can under-cover a tenant's competitors while over-matching on broad93 infrastructure terms. Nothing in the envelope flags either case.9495Neither is a reason to distrust the envelope's data blocks - they are the reason96to derive the *taxonomy* yourself, in two calls:97981. `get_tenant_settings(tenant_id)` - returns the full99 `account_research.queries_sections` (`competitors`, `organization`,100 `technologies`, `cloud_providers`), plus `golden_persona` and101 `display_names_mapping`.1022. `resolve_insights(concepts=[...], kind="technology")` - ONE call carrying103 every `competitors` + `technologies` value, **with the trailing `" Insight"`104 suffix stripped**. Tenant config stores vendors with that suffix and it is105 not part of any catalog name, so leaving it on drops every concept to the106 `partial` tier - where the top candidate may be the wrong kind entirely (a107 suffixed `Datadog` ranks the persona `data` first), too broad, or right but108 unverified. Stripped, the same vendors resolve `match: "exact"`. See the109 worked before/after tables in `references/ask-onfire-signals.md`.110111Then resolve the personas the same way with `kind="persona"`: every112`queries_sections.organization` value plus `golden_persona`.113114**Match-tier gate (non-negotiable).** Every candidate carries a `match` tier.115Keep `exact` and `synonym`. Treat `partial` and `fuzzy` as unresolved and never116search on them. Do not try to judge a `partial` candidate by whether its value117looks plausible - some are right and some are a persona wearing a technology's118name; the tier is the signal, not the value. Never render the unresolved list in119the customer report; it is an internal detail.120121### Use cases: prefer the tenant's own taxonomy122123Build the use-case set from the resolved `organization` personas, labelled via124`display_names_mapping` (the tenant already ships human labels, e.g.125`email_security` -> "Email Security Specialist"). Keep 3-5, ordered by the126evidence you actually found for this account in Step 1d.127128This deliberately overrides `tenant_config.derived_use_cases`. When you do:129130- Colors come **only** from `render_spec.use_case_palette`. Assign an existing131 palette entry per card; never invent a hex, never emit a tag class that is not132 a palette key.133- Never render a card whose only evidence is a single partial keyword match.134- Every card must position the tenant on the product it actually sells. If a135 card has no honest tie to that product, drop the card - do not manufacture an136 angle.137- Fall back to the orchestrator's `derived_use_cases` when138 `get_tenant_settings` errors or the tenant has no `organization` list.139140141## Step 1b - Prospect set: read the envelope first, poll only if needed142143The orchestrator already runs `ai_prospecting(action="run", use_cache=True)`144inside its own fan-out, so the envelope's `prospects` block is that tool's145output, not a lesser copy of it.146147- `envelope.prospects.status == "completed"` -> **use it as-is.** Do not re-call148 `ai_prospecting`; that spends another round trip of up to 50s for rows you149 already hold.150- `still_running`, `skipped`, or an `error` -> poll with151 `ai_prospecting(action="run", company_linkedin_url="<url>")`, re-calling with152 the returned `run_ids` (or identical arguments) until `status="completed"`.153 Phoenix dedups server-side, so this joins the in-flight run instead of154 starting a second one.155- Prospecting needs the company LinkedIn URL. Resolve it with the156 `match-company` skill first when the envelope has none.157158Render every prospect from whichever response completed - both the inline159`prospects` array and the preview-shape `top_picks` + `preview_rows`. Treat its160`dataset.id` as the authoritative prospect dataset for `query_datasets` slicing161and for `download_dataset`.162163When `prospecting_enabled` is false, or the completed response carries zero164prospects, do **not** drop Section 8. Fill it from the Step 1d contact sources:165active hiring managers and golden-persona contacts, each labelled with where it166came from.167168169## Step 1c - Load the prospecting field glossary (REQUIRED when prospects are present)170171**Fetch this concurrently with Step 1**, not after prospects land - it has no172dependency on either.173174The `ai_prospecting` response carries fields whose meaning is non-obvious175and easy to invert (e.g. `MASTER_SCORE_PRIORITY` is a tier where **lower176is better**; `SCORE_WARM_INTRO` is an enum -- `PLATINUM > GOLD >177SILVER > COLD` -- not a number). Misinterpreting these silently178produces wrong reports. Before rendering any prospect, call:179180```181Onfire MCP: ai_prospecting_field_glossary()182```183184185This returns a self-describing contract for every prospect field:186`type`, `values` (enum or bounded range), `what_it_means`, `how_to_use`,187and examples. Use it as the authoritative source for:188189- **What "good" looks like** on every score (`COMPOSITE_SCORE` is190 bounded 0-1500; >800 is top-decile, <400 is a stretch).191- **Tier direction** -- `MASTER_SCORE_PRIORITY=1` is the actionable192 cohort, not tier 5.193- **Warm-intro enum ordering** -- PLATINUM (alumni at target) is the194 highest-leverage path; COLD requires cold outbound.195- **Boolean signals** -- `WORKED_IN_CLIENT_COMPANY_IN_PAST=true` is196 the alumni flag, the highest-value expansion play.197- **Which fields are ready-made copy** -- `product_talking_points` and198 `ai_reasoning` are pre-written outreach payload; never rewrite, just199 surface verbatim.200201The `ai_prospecting` response also carries:202- `field_glossary_resource_uri` - the MCP resource URI for the same203 glossary. Clients that auto-inject resources will load it without204 an explicit call; on other clients fall back to the tool.205- `field_index` - the sorted list of every field name as a fast206 schema-drift check. If a field in `top_picks` is missing from207 `field_index`, treat it as unverified and skip rendering it rather208 than guessing.209210When `ai_prospecting` returned zero prospects, do not call the211glossary -- there's nothing to interpret yet.212213## Step 1d - Enrich with `ask_onfire` warehouse signals (REQUIRED)214215The orchestrator pulls four blocks (filings, footprint, intent signals,216prospects). Several signal surfaces that materially decide whether this report217is worth reading are **not** in the envelope. Pull them, scoped to the account218by its LinkedIn URL.219220This step used to be optional and self-selected, which is why two runs of the221same account produced different reports. The mandatory set below is now fixed.222223`ask_onfire` takes a structured `QueryIR` (entity + filters + insight_filters +224limit), validates it against the semantic model, and returns rows. **Read225`references/ask-onfire-signals.md` for the exact per-entity recipes.** Never226guess field names and never write SQL.227228### Schema lookups: one call, not one per entity229230`describe_onfire_schema` accepts a **list**. When you need to confirm fields,231make a single `describe_onfire_schema(["job_post", "hiring_manager_signal",232...])` call rather than one per entity.233234### Mandatory pulls235236| Pull | Entity | Feeds |237|---|---|---|238| Open roles / hiring momentum | `job_post` | Why-Now (Section 3), use-case account-signals |239| Active hiring managers (decision-makers) | `hiring_manager_signal` | Key contacts (Section 8), Why-Now |240| Growth direction | `growth_insight_monthly` or `headcount_monthly` | Why-Now, company card |241| **Competitor / technology footprint** (the Step 1a resolved list) | `contact` + `insight_filters` `kind=technology` | Confirmed deployment (Section 4) |242| **Golden-persona contacts** | `contact` + `insight_filters` `kind=persona` | Key contacts (Section 8), use-case cards |243244The last two exist because the orchestrator's own footprint searched a truncated,245badly resolved keyword list. Yours searches the vendors the tenant actually246competes with and the persona it actually sells to.247248### Optional pulls - only when a section is still thin249250`event_company` / `event_contact` (event presence), `insight_evidence` (dated251"in production since" proof), `product_adoption` (incumbent adoption quarter and252likely renewal quarter - **BETA, both dates are estimates**, so any figure from253it must be labelled as an estimate), `github_member` (developer engagement),254`people_experiences` (alumni / warm-path context).255256### Two mechanics that matter2572581. **OR in a single call.** An `insight_filters` entry accepts a **list** as its259 `value`, so the whole competitor set is one query, not one per vendor:260 `insight_filters: [{kind: "technology", value: ["CrowdStrike",261 "SentinelOne", "Microsoft Defender"]}]`. Separate entries AND together; a262 list inside one entry ORs.2632. **Always set an explicit `limit`.** A direct `ask_onfire` call returns rows264 per the limit you set; 5-10 is plenty for report evidence, 25-30 for the265 competitor footprint. If the response is `needs_confirmation`266 (`stage: "row_budget"`) it returned no rows - tighten a filter or lower267 `limit` and resubmit. Do not blindly set `confirmed: true`.268269Requires `company_linkedin_url` (reuse `company.linkedin_url` from the envelope,270or resolve via the `match-company` skill). Without it, skip this step and render271from the orchestrator blocks alone.272273## Step 1e - Company profile when the account is not an SEC filer274275`filings_10k.found = false` is the common case, not the exception - non-US and276privately held companies have no 10-K. When it is false, do **not** narrate277figures you cannot source. Build the company card from:278279- `ask_onfire` on `company` for firmographics (industry, HQ, size band, type),280- `get_company_headcount` for current headcount,281- the Step 1d `headcount_monthly` pull for direction,282- `search_offices` when geographic footprint is relevant.283284Any figure that does not come from one of those must carry its source inline in285the card, or be left out. An empty stat is better than an uncited one.286287288## Response shape (the envelope you render from)289290```291{292 "status": "completed" | "still_running",293 "company": { website, linkedin_url, name, ticker, latest_filing_date, ... },294 "tenant_config": {295 "golden_persona", "prospecting_enabled",296 "derived_use_cases": [{id, label, tag, evidence_count, ...}],297 "excluded_use_cases": [...],298 "footprint_keywords": [...]299 },300 "filings_10k": { found, filings: [{sections: {...}, keyword_hits: [...]}],301 dataset: { id, ... } },302 "linkedin_footprint": {303 // INSIGHT-BASED: people at the company who CARRY the tenant's304 // technology insights (the orchestrator resolves the tenant's305 // tech/competitor keywords to canonical technology insight_names306 // and pulls active employees carrying each, via the semantic layer).307 dataset, preview_rows, top_profiles,308 "facets": { "by_keyword": { /* keyed by resolved insight name */ } },309 "resolved_technologies": [ /* canonical insight names searched */ ],310 "unresolved_keywords": [ /* tenant keywords not in the catalog */ ],311 "total_matched": 0 // present only when more matched than returned312 // each top_profiles / preview row carries: matched_keyword (the313 // resolved technology insight), matched_insights (list), evidence_term,314 // and evidence_sentence (BEST-EFFORT — may be null; see Section 4)315 },316 "intent_signals": { dataset, preview_rows, facets, total_count },317 "prospects": { /* the orchestrator's own ai_prospecting run - see Step 1b */ },318 "datasets": { filings_10k, linkedin_footprint, intent_signals, prospects },319 "render_spec": {320 "section_order": [...],321 "hard_rules": [...],322 "use_case_palette": {...},323 "page_setup": {...},324 "pre_delivery_checklist": [...],325 "follow_up_tools": {...}326 }327}328```329330## Use the inline `render_spec` (and the fixed Onfire palette)331332The orchestrator ships the rendering contract inline. Do not invent your333own section order, palette, or rules. Read each from `render_spec`:334335- `render_spec.section_order` - the canonical section order336- `render_spec.hard_rules` - every constraint you must apply337- `render_spec.use_case_palette` - the only colors allowed for use case tags338- `render_spec.page_setup` - A4 dimensions, font stack, print-color-adjust CSS339- `render_spec.pre_delivery_checklist` - the server's baseline checks; Step 3340 runs those plus the skill's own (see Step 3)341342**The report's color palette is Onfire's, not the tenant's.** The343`--brand` (navy) / `--accent` (purple) tokens are hard-coded in the CSS344block in `references/report-structure.md` and do not vary per tenant.345Ignore `tenant_config.brand.primary` if present — it's legacy. The only346tenant-driven content in the header bar is the display name and logo:347348| Render value | Source | Fallback when absent |349|---|---|---|350| Tenant display name (header + footer) | `tenant_config.tenant_id`, title-cased | always apply — no display_name field exists |351| Tenant logo (header + footer) | not available in tenant config | always omit logo; render text wordmark only |352353If `render_spec` is missing or empty (older orchestrator version), use354the defaults documented in `references/report-structure.md` as a fallback,355but always prefer the inline contract.356357---358359## Step 2 - Render the report360361Read `references/report-structure.md` for the full A4 HTML template and362component snippets.363364### Section order (from `render_spec.section_order`)3653661. **Header bar** - brand-colored full-width bar with tenant logo367 (base64, when provided), brand display name, "Account Research -368 [Company]" eyebrow, and date.3692. **Company header card** - name (LinkedIn link), ticker, HQ, stat370 grid, overview. When `filings_10k.found` is false, build the stat grid371 from the Step 1e warehouse pulls; every figure carries a source or is372 dropped. Never narrate an uncited financial number.3733. **Why this account - why now** - 3-5 points sourced from374 `filings_10k.filings[].sections`, `intent_signals.preview_rows`,375 the footprint dataset, **and the Step 1d `ask_onfire`376 enrichment** — open-role surges (`job_post`, with `date_posted`),377 active hiring managers (`hiring_manager_signal`, with `signal_date`),378 event presence (`event_company` attendee counts, with the event379 year), and rising adoption (`growth_insight_monthly` /380 `headcount_monthly`, citing the month + growth direction). Every381 point carries a parenthetical date or "current role" citation. Render382 either as numbered prose rows or as a severity-tinted alert stack383 (see `references/report-structure.md` Section 3 Style A vs B).3844. **Confirmed technology deployment** - render from the **Step 1d385 competitor/technology footprint pull**, plus the orchestrator's386 footprint **dataset** (not its `top_profiles`).387 `top_profiles` is a **small inline preview**, not the result set - it388 commonly carries 3 rows where the account matched dozens of people. The389 dataset holds the full pull; slice it instead. It typically yields 20-30390 evidence-backed rows:391 ```392 query_datasets(393 datasets={"footprint": "<envelope.datasets.linkedin_footprint>"},394 sql="SELECT full_name, linkedin_url, job_title, location_name,395 matched_keyword, evidence_sentence396 FROM footprint WHERE evidence_sentence IS NOT NULL"397 )398 ```399 Dataset slicing is free, so there is no reason to render only 3.400 **Confirm only what resolved cleanly.** A confirmation label may name a401 vendor only if it came back `exact` or `synonym` from Step 1a. If a row's402 `matched_keyword` is not a **product name** - a hardware architecture, a403 networking or infrastructure category, an analysis-technique acronym, or a404 vendor's parent brand where the config named a specific product - it is not405 a deployment confirmation. Drop the row instead of claiming a vendor is in406 place. Test: could you say "the account runs <matched_keyword>" to their407 CISO without it sounding wrong? If not, drop it.408 Each person genuinely carries the insight, and `matched_keyword` is the409 **canonical technology name** (use it for the confirmation label, e.g.410 "CrowdStrike confirmed"). `evidence_sentence` is **best-effort and may be411 null** (the insight tag does not require the literal term in the bio):412 - When present, quote it **verbatim** in the evidence block (same413 rule as before).414 - When null, render the confirmation from the matched technology415 without a fabricated quote — state the person carries the416 deployment signal; do NOT invent a sentence.417 Optionally strengthen an entry with a Step 1d `insight_evidence` pull418 to add a "in production since [start_date]" date. Skip the whole419 section only when both the Step 1d pull and the dataset are empty.4205. **Intent signals** - render `intent_signals.preview_rows` with each421 signal's `message_text` quoted **verbatim** in a grey evidence block.422 See "Quote, never rewrite" below. Omit the entire section when423 there are zero signals - do not render a negative-state placeholder.424 This block is scoped by an exact `account_website` match and is425 genuinely sparse (commonly 0-2 rows per account), so a thin or absent426 section here is normal. Do not compensate by promoting Step 1d427 enrichment into it and labelling it an intent signal - hiring activity428 is hiring activity. If the account may be filed under a sibling domain,429 one extra `query_intent_signals` call with that domain is worthwhile.430 Some rows are prior-relationship signals - a person the tenant sold to431 or worked with at a former customer, now at this account. Slice them432 with `WHERE signal_type IN ('Champion Moved','Contact Moved','Champion433 Move','Ex-Customer Contact Move','Ex-Customer Hire')`, filtering on434 `signal_type` and never `source_name`. Tag the `Champion` types "Former435 champion" and the rest "Known contact" - never a contact as a champion -436 and show the move date, which is often years old.4376. **Solution fit divider + section head** - hairline divider followed438 by a single eyebrow line "Solution fit - [Tenant Display Name] use439 cases at [Account Display Name]" introducing the use case cards440 (no separate title/subtitle).4417. **Use case cards** - one per entry in the **Step 1a derived use-case442 set** (which overrides `tenant_config.derived_use_cases`; see Step 1a443 for why), ordered by the evidence actually found for this account.444 Keep 3-5. Drop any card whose only evidence is a single partial keyword445 match, and any card with no honest tie to what the tenant sells - a446 shorter report beats a wrong one. Every card's alignment column447 positions the tenant on **its own product**; never argue an adjacent448 security category the tenant does not sell. Each card pulls relevant449 signals + verbatim talking-point450 quote (10-K, LinkedIn profile, public talk, or any other verifiable451 source - see `report-structure.md` "Talking-points source citation")452 + prospect rows that map to that use case. The right column is453 brand-named: render its label as "[Tenant Display Name] solution454 alignment" (e.g. "Artifex solution alignment"). Tag colors come from455 `render_spec.use_case_palette` keyed by the use case `tag` - never456 invent a color.4578. **Key contacts per use case** - `break-before: page`, color-coded458 from `render_spec.use_case_palette` by the use case `tag`. Each459 contact card must render the fields the460 `ai_prospecting_field_glossary` `how_to_use` guidance calls out:461 warm-intro tier + connector name + shared company, composite score462 with breakdown, top three personas from `CURRENT_PERSONAS`,463 `PAST_COMPANIES_USED_CLIENT_TECH` when non-empty, career-momentum464 signals, the `ai_reasoning` bullets verbatim, and an opener from465 `product_talking_points`. Do not drop these fields silently -466 consistency across contact cards matters.467 **Active hiring managers** from the Step 1d `hiring_manager_signal`468 pull are a complementary contact source: a person actively building a469 team is a live decision-maker / budget owner. Surface them alongside470 the `ai_prospecting` contacts (tag them "actively hiring -471 [job_post_title]"), mapping each to its use case via the role being472 hired for.473 **Golden-persona contacts** from the Step 1d persona pull are the second474 complementary source: people at the account carrying the tenant's475 `golden_persona` are its literal buying persona. Tag them with the476 persona's `display_names_mapping` label.477 When prospecting is disabled or returns zero, these two sources carry478 Section 8 on their own. Label each contact with the source it came from,479 and never print a bare "no prospect list available this cycle" line as480 the section's only content - if all three sources are empty, omit the481 section.482483When surfacing prospect rows in sections 7 and 8, interpret every484field through the `ai_prospecting_field_glossary` contract loaded485in Step 1c - never invent score semantics.486487### Hard rules (from `render_spec.hard_rules` - non-negotiable)488489- **No em dashes** anywhere outside verbatim evidence quotes. Use a490 regular hyphen `-`.491- **No internal tool names** anywhere in the HTML. Never write Metabase,492 Snowflake, Phoenix, Onfire, MCP. Use: "market intelligence", "intent493 signals", "public filings", "industry research".494- **Signal messages quoted verbatim** - never paraphrase or reframe.495 Trim with leading/trailing ellipsis only.496- **Company name is a LinkedIn link** - `<a href="[linkedin]">` with a497 1.5pt dotted underline in `var(--faint)`.498- **Brand colors are fixed Onfire tokens** - `var(--brand)` (navy499 `#0A2540`) and `var(--accent)` (purple `#7C5CFF`) are hard-coded in500 the CSS block. Do not hardcode hex literals; do not pull501 `tenant_config.brand.primary` to override them. The report is502 Onfire-branded; tenant brand surfaces only as text/logo content in503 the header bar.504- **Footer** - company name, Account Research, [Month Year]. Nothing else.505- **No buying committee or cold opens section.**506- **System fonts only** - no Google Fonts CDN (file:// blocks it).507508### Evidence block - quote, never rewrite (CRITICAL)509510For every signal with a non-empty `message_text`, render that message511in a grey evidence block as a **verbatim** excerpt. You MAY trim with512leading/trailing ellipses (...) to focus on the relevant span, but you513MUST NOT paraphrase, summarize, translate, fix typos, reflow whitespace,514or otherwise alter the characters inside the quoted span. The text515inside the quote must be a contiguous substring of `message_text`516byte-for-byte. **Never substitute `short_summary` or any other column**517- for Company Change and Promotion signals as well, the evidence block518is `message_text` or nothing. If `message_text` is empty or null,519render `(no message text on record)` or skip the evidence block; do520not fabricate or substitute another field.521522The same rule applies to every `evidence_sentence` - from `top_profiles`,523from the sliced footprint dataset, or from a Step 1d pull.524525### Page setup (from `render_spec.page_setup`)526527- `@page { size: A4; margin: 16mm 18mm 18mm 18mm }`528- `body { width: 174mm }`529- Font stack: `-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif`530- All font sizes in `pt`: body 9pt, labels 7pt, headings 11-15pt531- `print-color-adjust: exact` rule in `@media print` (preserves background colors when printing)532- Every `.card` has `break-inside: avoid`533- Key contacts sections have `break-before: page`534535### Use case palette (from `render_spec.use_case_palette`)536537Only use the tag classes that appear in the palette. The palette is the538**colour** contract; Step 1a owns the **card set**. So: render the Step 1a539use cases, and assign each one an existing `use_case_palette` entry.540541- Never invent a colour and never emit a tag class that is not a palette key.542- Reuse a palette entry whose semantics are closest to the card, or simply543 assign entries in order. The palette has 7 slots; keep to 3-5 cards.544- Never assume a fixed list of use cases (no hardcoded "four canonical545 use cases").546- If a card has no sensible palette entry, fall back to the neutral547 `--low-bg` / `--low-text` tokens rather than guessing a hex.548549---550551## Step 3 - Pre-delivery checklist (from `render_spec.pre_delivery_checklist`)552553Before saving the final HTML and calling `present_files`, run every check554below. All must pass.5555561. **Use case tags constrained to the palette**557 `grep -oE 'class="tag" style="background:var\\(--[a-z]+-bg' report.html`558 Every tag class must be one of the palette keys in559 `render_spec.use_case_palette`. No invented tags.5605612. **No internal tool names, no em dashes outside verbatim quotes**562 One pass does both:563 `grep -niE 'phoenix|metabase|mcp|onfire|—' report.html`564 Zero matches, except a U+2014 inside a `class="evidence"` /565 `class="quote"` block (those preserve `message_text` byte-for-byte).5665673. **Why Now evidence references**568 Every `<div class="why-row">` body must contain a parenthetical in569 its bold strong tag - `(... [date] / [date range] / "current role")`570 - with one of the acceptable source types (10-K, LinkedIn profile,571 conference, community Slack/Discord, LinkedIn post, company-change572 records). No date-less Why Now points.5735744. **Prospect field interpretation**575 If the `ai_prospecting` response from Step 1b carries real rows576 (not `still_running` / zero-result), confirm577 `ai_prospecting_field_glossary` was loaded and every578 prospect-derived rendering decision (tier label, warm-intro579 wording, score commentary) traces to a `what_it_means` /580 `how_to_use` entry in the glossary. If you cannot point to the581 glossary entry that justifies a phrase, remove the phrase.5825835. **Prospect source provenance**584 Every prospect must come from a **completed** `ai_prospecting` response -585 either the envelope's block (Step 1b, preferred) or the standalone poll.586 Never render rows from a `still_running` response.5875886. **Vendor confirmations traced to a clean resolution**589 Every "X confirmed" label in the Confirmed-deployment section names a590 vendor that resolved `exact` or `synonym` in Step 1a, and the label names a591 product - never a hardware architecture, an infrastructure category, an592 analysis-technique acronym, or a parent brand standing in for a specific593 product. If you cannot point at the resolution that justifies a label,594 remove the row.5955967. **Deployment section not needlessly truncated**597 If the footprint dataset holds more evidence-backed rows than the report598 renders, you rendered the inline preview instead of slicing the dataset.599 Go back to Section 4.6006018. **Use cases are the tenant's own, and honestly positioned**602 Each card traces to a Step 1a persona (or to the documented fallback),603 no card rests on a single partial keyword match, and no card argues a604 product category the tenant does not sell.605606If any check fails, fix the report and rerun all checks. Do not607deliver until all pass.608609---610611## Step 4 - Output612613### A4 HTML file614615Generate a **fully self-contained** HTML file:616617- Tenant logo embedded as **base64 data URI** - no external image references618- System font stack only - no Google Fonts CDN619- `print-color-adjust: exact` CSS in `@media print`620- Save to `/mnt/user-data/outputs/account-research-<company>-<tenant>.html`621- Call `present_files`622623### PDF instructions for user624625Tell the user:626> "To convert to PDF: open in Chrome -> Cmd/Ctrl+P -> Save as PDF -> enable **Background graphics** -> Save."627628---629630## Handling follow-up questions631632The orchestrator ships three dataset IDs in `envelope.datasets`633(`filings_10k`, `linkedin_footprint`, `intent_signals`). The634`ai_prospecting` call from Step 1b ships the fourth — the prospects635dataset — on its own response (`dataset.id`). Every slicing question636reuses those datasets via `query_datasets` - no re-orchestration, no637new SQL.638639### Slice already-pulled data (zero-cost follow-ups)640641For questions like "break down signals by source", "show me only SecureCon642attendees", "give me all the prospects, not just the top 10":643644```645query_datasets(646 dataset_id="<envelope.datasets.intent_signals | envelope.datasets.filings_10k647 | envelope.datasets.linkedin_footprint648 | ai_prospecting_response.dataset.id>",649 sql="SELECT ... FROM dataset WHERE ..."650)651```652653Common patterns:654- Signal source mix: `SELECT source_name, COUNT(*) FROM dataset GROUP BY 1`655- Filter signals by event: `WHERE source_name = 'SecureCon 2026'`656- Prior-relationship signals: `WHERE signal_type IN ('Champion Moved',657 'Contact Moved', 'Champion Move', 'Ex-Customer Contact Move',658 'Ex-Customer Hire')`659- Filter prospects by team: `WHERE LOWER(TITLE_NAME) LIKE '%cloud%'`660- Pull a specific 10-K paragraph: `SELECT FULL_MARKDOWN FROM dataset` then661 substring locally.662663### Pull truly new data (Layer 3 typed tools)664665When the user asks for data the orchestrator didn't pull, call the666relevant narrow typed tool. **Never write raw SQL.**667668| User asks for | Call |669|---------------|------|670| Signals on a topic outside the tenant's keyword set (e.g. NIS2, DORA) | `query_intent_signals(tenant_id, account_website, keyword_match=[...])` |671| A 10-K section the report didn't surface (e.g. a specific exec name) | `query_company_filings(website, keywords=[...])` |672| Employees carrying a different product / competitor | `ask_onfire` — `entity=contact`, filter `current_company_url eq <url>`, `insight_filters=[{kind:technology, value:[<product>, ...]}]` — a **list ORs in one call** (NOT a raw `JOB_SUMMARY` ILIKE) |673| People in a given role / persona at the account | `ask_onfire` — `entity=contact`, filter `current_company_url eq <url>`, `insight_filters=[{kind:persona, value:<resolved persona>}]` |674| When the incumbent was adopted / when they renew | `ask_onfire` — `entity=product_adoption`, filter `company_linkedin_url eq <url>` (BETA - estimates, label them) |675| Firmographics for a company with no 10-K | `ask_onfire` — `entity=company`, filter `linkedin_url eq <url>`, plus `get_company_headcount` |676| More employee-footprint rows than the report showed | `query_datasets` on `envelope.datasets.linkedin_footprint` — free, no row budget |677| Open roles / what the company is hiring for | `ask_onfire` — `entity=job_post`, filter `company_url eq <url>` (+ `job_function`/`seniority`/`open`) |678| Who is actively hiring (decision-makers) | `ask_onfire` — `entity=hiring_manager_signal`, filter `company_url eq <url>` (+ `person_seniority`) |679| Who attended an event / company event presence | `ask_onfire` — `entity=event_contact` (who) or `event_company` (counts), filter `event eq <resolved>` + `company_url eq <url>` |680| Is a persona/tech adoption growing at the account | `ask_onfire` — `entity=growth_insight_monthly`, filter `company_url eq <url>` + `insight eq <resolved>`, order by `month` |681| Headcount growth trend | `ask_onfire` — `entity=headcount_monthly`, filter `company_url eq <url>`, order by `month` |682| Since-when / proof behind a signal | `ask_onfire` — `entity=insight_evidence`, filter `company_url eq <url>` + `insight_value eq <resolved>` |683| Developers engaging with an OSS repo | `ask_onfire` — `entity=github_member`, filter `repo_name`/`activity`, join `contact` |684| Where a person worked before / alumni of the account | `ask_onfire` — `entity=people_experiences`, filter `company_url eq <url>` + `current=false` |685686See `references/ask-onfire-signals.md` for the full worked `QueryIR` of687each recipe, the bound-concept resolution step, and the per-row billing688rule. Each tool/query returns its own dataset, so its output is also689further sliceable via `query_datasets`.690691---692693## Error handling694695| Situation | Action |696|-----------|--------|697| `account_research` returns `status="still_running"` solely because of prospecting | Use the completed non-prospect blocks and poll prospects per Step 1b. Do not re-call the orchestrator. |698| `get_tenant_settings` fails or the tenant has no `organization` list | Fall back to `tenant_config.derived_use_cases`, and treat the resulting use-case set as generic rather than tenant-specific. |699| Every vendor in Step 1a resolves only `partial` / `fuzzy` | Run no technology footprint pull. Render Confirmed deployment from the orchestrator dataset only, and only for rows whose `matched_keyword` is a real vendor. Never confirm a vendor off a partial match. |700| Step 1d competitor footprint returns zero rows | Normal for a small or thinly covered account. Fall back to the orchestrator's footprint dataset; if that is empty too, omit the section. |701| Golden-persona pull returns zero rows | Section 8 falls back to hiring managers, then to prospects. If all are empty, omit Section 8. |702| `linkedin_footprint.skipped` is `true` | Skip the "Confirmed deployment" section silently. |703| `linkedin_footprint` returns zero people (`total_count` 0), or every tenant keyword is in `unresolved_keywords` | Fall back to the Step 1d pull; skip the section only if both are empty. Never render `unresolved_keywords` in the customer report (internal detail). An empty `top_profiles` alone is NOT an empty footprint - it is only a preview; check `total_count` and the dataset. |704| `linkedin_footprint` row has a null `evidence_sentence` | Render the confirmation from `matched_keyword` without a quoted evidence block; never fabricate a sentence. |705| `intent_signals.total_count` is 0 | Omit the Intent signals section entirely (per Section 5). Common and expected - do not backfill it with Step 1d enrichment. |706| Step 1d `ask_onfire` returns `needs_confirmation` (`stage: "row_budget"`) | No rows billed. Lower `limit` to what the section needs and resubmit; do not blindly set `confirmed: true`. |707| Step 1d `ask_onfire` returns zero rows or an `error` | Skip that enrichment silently; render from the orchestrator blocks. Never fail the report. |708| `ai_prospecting` returns `status="still_running"` | Re-call with the returned `run_ids` (or identical args). Phoenix dedups server-side. |709| `ai_prospecting` returns zero prospects (`top_picks: []`) or `tenant_config.prospecting_enabled` is `false` | Drop the prospect columns in Section 7 cards, but keep Section 8 and fill it from the Step 1d hiring-manager and golden-persona pulls. Omit Section 8 only when all three sources are empty. |710| Company has no LinkedIn URL even after `match-company` | Skip Steps 1b and 1d entirely; render from `company_website`-scoped blocks only. Step 1a still runs - the taxonomy is tenant-scoped, not account-scoped. |711| `filings_10k.found` is false | Expected for non-US and private companies. Run Step 1e and source every company-card figure. |712| One of `*.error` keys is set | Skip that section; never fail the whole report. |713714---715716## Reference files717718- `references/report-structure.md` - Full HTML template, CSS, layout rules719- `references/ask-onfire-signals.md` - Step 1a resolution gate + Step 1d `ask_onfire` QueryIR recipes (hiring, events, growth, dated proof, github, alumni, competitor footprint, golden-persona contacts, product adoption, firmographics) + row-budget rules720- `references/persona-to-usecase.md` - Map prospect titles -> use cases for the use-case-cards section721- `references/pdf-generation.md` - PDF conversion instructions722- `references/use-case-mapping.md` - (informational) the keyword-bucket mapping the orchestrator uses server-side; the skill no longer applies this mapping itself723- `references/10k-extraction.md` - (informational) the substring-extraction rules the orchestrator applies server-side; the skill no longer extracts 10-K sections itself