# The GTM OS tools

Each key opens 1 workspace and has scopes: `read`, `setup:write`,
`contacts:write` and `content:write`. You see only the tools that the key's
scopes allow. Each result has `facts`, and can have `inferences`, `unknown`
and `page`. A field in `unknown` was not found; do not treat it as empty.
A refusal is a result with `isError`. Read it, correct the input, try once.

Text limits: a name or a title has at most 120 characters, a bullet 400, a
paragraph 4,000. A list field has at most 30 items. A call has at most 50
records. Text may not contain `<untrusted`.

A write tool never overwrites a field that has a value, unless the call sends
`replace: true`. It never deletes a record, never deletes a field that you do
not send, and never renames a record. The result names the fields it kept and
the fields it wrote. The contact tools are different: see them below.

## Before a workspace exists

### create_workspace
Only for a person with no workspace. Input `{ inviteCode, name, currentCrm,
currentCrmName?, timeZone?, acceptTerms: true }`. `currentCrm` is `hubspot`,
`attio`, `salesforce`, `pipedrive`, `other`, `spreadsheet` or `none`. Ask the
person for each value, and show them the terms and the privacy policy before
you send `acceptTerms`. It makes the workspace with the person as its admin,
sends a welcome email, and gives this agent every scope there. The next call
works in the new workspace. A bad or used code is refused. At most 3 an hour.

## Read tools

### get_setup_status
Scope `read`. Input `{}`. The 13 setup items of the workspace: done or not,
a detail, and the dashboard page that sets it. Call it first and last.

### get_messaging
Scope `read`. Input `{ detail?: "summary" | "full", sections?: ("strategy" |
"personas" | "spokespeople" | "outreach")[], includeArchived?: boolean }`. The
strategy (pillars, principles, foundational messaging, vision and mission,
narrative, reasons to believe), the personas, the spokespeople and the outreach
context. Summary gives names, ids and which persona fields are filled. Call it
before a write tool, for the ids. Narrative and reasons to believe are read
only here. Archived personas are left out unless `includeArchived` is true.
If the full personas are more than 40,000 characters, they come in the summary
form and `personaDetail` is "summary". Then call `get_persona` for each id.

### get_persona
Scope `read`. Input `{ id }`. Every field of 1 persona. Use it when
`get_messaging` sends the personas in the summary form.

### get_prospect_types
Scope `read`. Input `{}`. The prospect types, the ones hidden on first load,
and `isDefault`, true when the workspace uses the default list.

### get_icp_groups
Scope `read`. Input `{}`. The ICP groups in match order (`position`), with
their keywords, industry tags and colour, and the companies and contacts that
each group holds now. `unmatched` counts the companies with no group. The
first group that matches a company wins.

### get_company_facets
Scope `read`. Input `{ facet, q?, limit? }`. `facet` is `industry`, `country`,
`size`, `companyType`, `prospectType` or `icpGroup`. Each value with its
companies and contacts, the most companies first, 50 by default and 200 at
most. Call it with `industry` before `upsert_icp_groups`, to pick tags that
exist in the workspace. A size uses a hyphen, for example "11-50".

### search_companies
Scope `read`. Input `{ q?, industries?, countries?, sizeBuckets?,
prospectTypes?, icpGroupIds?, withoutIcpGroup?, cursor? }`. 50 companies a
page, the highest signal score first. `q` matches part of the company name,
so it shows what an ICP keyword would match. A filter matches a company when 1
of its contacts matches.

### search_contacts
Scope `read`. Input `{ q?, companyName?, persona?, stage?, icpGroupId?,
cursor? }`. 50 contacts a page, the highest signal score first. `q` matches
part of the name, the job title or the company. No email address is returned:
`hasEmail` says if one is stored.

### get_contact
Scope `read`. Input `{ id }`. 1 contact by its id from `search_contacts`, with
its location, company industry tags and country. No email address is returned.

### list_content
Scope `read`. Input `{ kind?, cursor? }`. With no kind: the count of each
kind. With a kind (`mvpr_coverage`, `mvpr_resource`, or a content kind): 50
rows and `page.next`. Send `page.next` as `cursor` for the next page. Call it
before `import_resources`, to see which MVPR ids are imported.

### list_feed_items
Scope `read`. Input `{ status?, sourceType?, unreviewedOnly?, withEvidence?,
cursor? }`. The themes of the Content Feed, 20 a page. `status` is `new`
(default), `sent`, `saved` or `discarded`. `sourceType: "mcp"` keeps the
themes made from `ingest_context`, and `sourceType: "tracked_profile"` the
themes made from Profile Tracker posts. Each row has `topPost`: the reactions,
comments and reposts of its most popular tracked post, or null. For a review pass send `unreviewedOnly`
and `withEvidence`. `kind: "sound_bite"` lists the Sound Bites feed instead:
what the workspace's spokespeople said, word for word, on recorded calls and
in Slack, each with its `score` (1 to 5), the speaker and the link to the
moment. The same 4 `status` words; `sent` is Kept. `kind: "linkedin_post"` lists
the LinkedIn Posts feed: every post the workspace tracks or published, with
LinkedIn's counts, how many people engaged, how many are ICP Engagement, and
the date it went to MVPR. The row's `key` is the id for the other tools.

### get_feed_item
Scope `read`. Input `{ kind?, id }`. 1 theme with all its evidence quotes. A quote
from a tracked LinkedIn post has its `metrics`. A document or a call is
content only and has none. `kind: "sound_bite"` gets 1 sound bite. `kind:
"linkedin_post"` gets the people who engaged with 1 post by its key: name,
jobTitle, company, persona, icpEngagement, liked or commented and the comment
text. No email and no LinkedIn URL leave.

### list_content_feeds
Scope `read`. No input. The feeds that a Profile Tracker campaign can send
its posts to, with their ids. Only a feed with `pickable: true` can be set
now. The Competitor feed comes in a later release.

### list_tracked_profiles
Scope `read`. No input. The Profile Tracker campaigns that track LinkedIn
posts, each with its feed, its last Sync and its profiles. No profile URL
and no email.

### get_sync_status
Scope `read`. Input `{ campaignId }`. The campaign's feed, the posts it
holds, the posts that wait for the AI classifier, and the time of its last
Sync. `run` is the open scrape with its progress, or null. `paid` shows the
key's 2 paid caps and what the key used.

### check_contact_duplicates
Scope `read`. Input `{ contacts: [row] }`, up to 500 rows. A row has
`firstName`, `lastName`, `fullName`, `jobTitle`, `companyName`, `email`,
`linkedinUrl`, `twitterUrl`, `location`, all optional. Each row needs an
`email`, a `linkedinUrl`, or a name with a `companyName`. A match is by
LinkedIn URL, then email, then name with company. The result gives, for each
row, the contact id and how it matched, or null. A bad row is refused with
its index. Nothing is written. Call it before `import_contacts`.

### list_email_campaigns
Scope `read`. Input `{}`. The cold outbound email campaigns with their send
state, totals and rates. Refused when the workspace has no email campaigns.

### get_email_campaign_stats
Scope `read`. Input `{ campaignId, days? }`, 1 to 365, default 30. The
statistics of 1 email campaign: sends, opens, clicks by link, responses,
bookings, the median time to each first action, and when the rest goes out.

### list_email_campaign_recipients
Scope `read`. Input `{ campaignId, tab?, cursor? }`. The people of 1 email
campaign, 50 a page, with the time of each first action. No email address
leaves. `tab` is `all`, `opened`, `clicked`, `responded`, `booked` or
`not_sent`.

### list_tracked_posts
Scope `read`. Input `{ handle?, from?, to?, sort?, hideStaff?, limit? }`.
The LinkedIn posts of 1 tracked author, up to 200, the newest first. Each
post has LinkedIn's reactions, comments and reposts, and the captured people
by persona and by stage. `sort` is `posted_at`, `people`, `reactions` or
`comments`. Staff are left out unless `hideStaff` is false. A post with
reactions and 0 captured people was not scraped: say "not captured".

### list_post_engagers
Scope `read`. Input `{ handle?, postId, type?, persona?, stage?, hideStaff?,
cursor? }`. The people who liked or commented on 1 post, 50 a page: contactId,
name, jobTitle, company, persona, icpEngagement (the person fits one of the
workspace's personas), stage, score, liked, reactionType and the text of
their comments. No email and no LinkedIn URL leave. `persona: "none"`
gives the people with no persona.

## Write tools

### set_strategy
Scope `setup:write`. Input `{ replace?, pillars?, principles?,
foundationalMessaging?, visionMission? }`. A pillar or principle is `{ id?,
title, oneLiner?, overview?, appliesToProducts?, appliesToPersonaIds?,
perPersona? }`. It matches by id, then by title. A new one goes at the end.
Persona ids must come from `get_messaging`. `foundationalMessaging` has
`whoWeAre`, `problem[]`, `toneOfVoice[]`, `elevatorPitch`, `boilerplate`,
`oneLine`, `standFor[]`, `standAgainst[]`. This tool does not write the
narrative or the reasons to believe.

### upsert_personas
Scope `setup:write`. Input `{ replace?, personas: [persona] }`, 1 to 50. A
persona is `{ id?, name, product?, headlineQuote?, matchPatterns?,
minEmployees?, maxEmployees?, matchCountries?, requireCompanyTraits?,
excludeFromSalesFunnel? }` plus paragraph fields (`whoTheyAre`,
`primaryJob`, `emotionalJob`, `positioning`, `language`, `dmPrinciples`,
`churnRisk`) and list fields (`characteristics`, `jobsToBeDone`,
`valueProps`, `painPoints`, `desiredOutcomes`, `proofPoints`, `objectives`,
`opportunities`, `commonObjections`, `ctas`, `redFlags`, `voiceOfCustomer`,
`valueLanguage`). It matches by id, then by name. `matchPatterns` are parts
of a job title. `matchCountries` are 2-letter country codes. A persona with
no pattern and no company trait matches no contact. Existing contacts are
not reclassified; the customer presses Reclassify contacts on the Messaging
page.

### upsert_icp_groups
Scope `setup:write`. Input `{ replace?, groups: [{ id?, name, keywords?,
industries?, color? }] }`, 1 to 50. Industries match before keywords.

### upsert_spokespeople
Scope `setup:write`. Input `{ replace?, spokespeople: [{ id?, name, role?,
bio?, positioning?, linkedinUrl? }] }`, 1 to 50. It matches by id, then by
name. A spokesperson has no fingerprint until `generate_fingerprint` makes
one.

### set_prospect_types
Scope `setup:write`. Input `{ replace?, types, defaultExcluded? }`. Without
`replace`, the types join the saved list. With the default list, or with
`replace`, the list becomes the types you send. "Excluded" always stays. It
never renames a type.

### generate_fingerprint
Scope `setup:write`. Paid. Input `{ target: "corporate" | "spokesperson",
spokespersonId?, channel?, samples: [text], replace? }`. A spokesperson needs
`spokespersonId` from `get_messaging` and a `channel`: `linkedin_post`,
`email` or `blog`. Send 300 words or more in total, at most 10 samples, in
the author's own words. If the target has a fingerprint, the call is refused
unless `replace: true`; the old version is kept. At most 20 calls a day for
each workspace. The result has a summary and the key traits.

### import_resources
Scope `setup:write`. Input `{ replace?, resources: [resource] }`, up to 50. A
resource is `{ mvprId, title, externalUse: true, mvprUrl?, tags?, summary,
body?, publishedAt? }`. `mvprId` is the id from `search_resources` and is the
match key. `summary` is 1 to 3 paragraphs, up to 1,500 characters; a draft
reads it. `externalUse` must be `true`: import only material that may be
quoted to a prospect. The resources go into the Resource Type "MVPR
resources".

### import_contacts
Scope `contacts:write`. Input `{ contacts: [row] }`, the same rows as
`check_contact_duplicates`, up to 500. Send 100 to 200 a call and wait for
each result. A bad row is refused with its index and the others import. No
`replace`: on a matched contact, a name, job title, company or location you
send replaces the stored value; an email or LinkedIn URL fills only an empty
field. A new contact starts at Prospect with no score.

### set_contact_personas
Scope `contacts:write`. Input `{ contacts: [{ contactId, persona }],
replace?, confirmToken? }`, 1 to 100 rows. `persona` is the name of an active
persona from `get_messaging`, or null to clear it. A row with an unknown name
or contactId is refused with its index, and the other rows save. A persona
that a person chose is kept and listed in `kept`. To overwrite it, call with
`replace: true`: that call writes nothing and returns the overwrites and a
`confirmToken` for 10 minutes. Show the overwrites, wait for a clear yes, then
call again with the same rows, `replace: true` and the token. For a new
persona, call `upsert_personas` first, then this tool.

### ingest_context
Scope `content:write`. Input `{ items: [{ sourceLabel, sourceApp?,
externalId?, title?, url?, text, author?, occurredAt? }] }`, up to 50 items,
each up to 20,000 characters. Send the text itself; the tool never fetches a
URL. With `sourceApp` and `externalId`, the same text is not stored twice.
The themes appear on the Content Feed within about an hour.

### review_feed_item
Scope `content:write`. Input `{ kind?, id, action, suggestion? }`. `action` is
`save_to_gtm_knowledge_base` (the theme goes to the GTM Knowledge Base, which
every campaign's drafter reads, and is kept; `mark_used` is its old name),
`save`, `discard`, `restore` or `suggest`. `kind: "sound_bite"`
reviews 1 sound bite the same way, and `suggest` is refused. A suggestion has a
`destination` (`faq`, `statistic`, `content_resource`, `narrative_input`,
`draft_seed`) and a `reason`; `faq` needs `faq.question`, `statistic` needs a
`statement`, a `valueText` that the evidence states, and a `metric`. A person
confirms every suggestion. The key has a daily cap of reviews.

### send_feed_item_to_pr_knowledge_base
Scope `content:write`. Input `{ kind, id }`. `kind` is `product_feature`,
`product_topic`, `product_faq`, `spoken_view`, `sound_bite`, `account_view`
(a Vision item: the file holds the belief, never the quote), `narrative_proposal`
(the suggestion, never its evidence) or `linkedin_post` (a post of the LinkedIn
Posts feed by its key, sent as a social media post, not a file). Sends 1 Content Feed item to
the workspace's MVPR account as a file in Resources, the PR Knowledge Base,
as the card's button does. Any item in the feed may go; a discarded item
cannot. A second call returns the first record. MVPR cannot remove the file
over its API, so show the person the item first.

### set_campaign_feed
Scope `content:write`. Input `{ campaignId, feedId }`. Set the feed that a
Profile Tracker campaign sends its posts to, as the dropdown in the campaign
drawer does. `feedId: null` returns it to the default, the own content feed.
Items already in a feed stay there. To stop 1 profile, use `set_profile_feed`.
After this, call `sync_campaign_feed`. Each new scrape then sends its posts
on its own.

### set_profile_feed
Scope `content:write`. Input `{ campaignId, handle, off }`. `off: true`
leaves 1 profile's posts out of the campaign's feed. `off: false` puts them
back. Posts already sent stay in the feed.

### sync_campaign_feed
Scope `content:write`. Input `{ campaignId }`. Send the posts that the
campaign already holds to its feed, as the Sync button does. It reads
nothing from LinkedIn, so it costs no scrape credit. The AI classifier uses
model credit. A second Sync sends only the posts that are new since the
last one. The themes appear on the Content Feed within about an hour.

`deeper: true` asks for a PAID new LinkedIn read of up to 50 posts for each
profile, posts only. That first call starts nothing. It returns a quote in
cents, a `confirmToken` and `expiresAt`, 10 minutes later. Show the quote to
the person and wait for a clear yes. Only then call again with `deeper`,
`confirmToken` and `acceptedCents`. A paid read is off until a workspace
admin sets the key's 2 paid caps: paid scrapes a day, and spend in 30 days.

### stop_campaign_sync
Scope `content:write`. Input `{ campaignId }`. Stop the campaign's open
scrape, as the Stop button in the drawer does. The actor call in flight is
still billed. The posts already stored stay, and the schedule continues.
