API reference

Wilson Agent Bridge

Wilson decides who to write and drafts the note. Your agent sends it, and tells Wilson what came back.

v1 stable Base URL https://app.meetwilson.ai Auth Bearer wsk_... PDF reference

Overview #

Wilson recommends communications, called Today cards: who to write, why now, and a ready-to-send draft in the owner's voice. Wilson never sends them itself. The agent bridge lets another agent pull those recommendations, send them over any channel, and report back what happened, including the person's reply. Wilson then holds the full back-and-forth on each person's timeline, and its next drafts are written with that context.

Wilson

Recommends

Who to write today, why it is timely, and a complete draft, ranked. Each row names the person, their email or phone, and the channel they prefer.

Your agent

Sends

You pull the list, send each note on the right channel, and tell Wilson it went out. If you edited the wording, you pass back what you actually sent.

The person

Replies

When they answer, you hand the reply to Wilson, which files it against the right person by card id, contact id, or email address.

Then the loop closes. The send counts as a personal touch, the reply is on the record, and tomorrow's recommendation for that person is written with both in view.

What you need #

  • A Wilson workspace, and an API key created by its owner (Authentication).
  • A way to send on at least one channel: email, WhatsApp, SMS, or anything else your agent can reach.
  • Somewhere to run a small loop, on a schedule or on demand. There is no SDK to install and no webhook to register. Three HTTP calls are the whole surface.

Quickstart #

Three calls, start to finish. Set WILSON_KEY to the key you created, then pull today's list, send the top one however you like, and tell Wilson what happened.

  1. Pull. GET /api/agent/v1/recommendations returns the ranked list. Each row carries the recipient and the complete draft.
  2. Send, then report. Deliver the message on your own channel, then POST the outcome so Wilson records the touch. Pass your provider's message id as external_message_id so a retry cannot double-log it.
  3. Import the reply. When they answer, POST it to /replies. Wilson files it against the right person and reads it when drafting next time.

Nothing here is destructive. Reporting an outcome twice is safe, and so is importing the same reply twice.

export WILSON_KEY="wsk_..."
export BASE="https://app.meetwilson.ai"

# 1. Pull today's recommendations
curl -s "$BASE/api/agent/v1/recommendations" \
  -H "Authorization: Bearer $WILSON_KEY" | jq '.recommendations[0]'

# 2. Send it on your channel, then tell Wilson
curl -s -X POST "$BASE/api/agent/v1/recommendations/$CARD_ID/outcome" \
  -H "Authorization: Bearer $WILSON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"outcome":"sent","channel":"whatsapp","external_message_id":"wamid.abc123"}'

# 3. When they answer, hand the reply back
curl -s -X POST "$BASE/api/agent/v1/replies" \
  -H "Authorization: Bearer $WILSON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"recommendation_id":"'$CARD_ID'","channel":"whatsapp",
       "body":"Thanks! Thursday works.","external_message_id":"wamid.def456"}'
const BASE = "https://app.meetwilson.ai";
const auth = { Authorization: `Bearer ${process.env.WILSON_KEY}` };
const json = { ...auth, "Content-Type": "application/json" };

// 1. Pull today's recommendations
const res = await fetch(`${BASE}/api/agent/v1/recommendations`, { headers: auth });
const { recommendations } = await res.json();
const card = recommendations[0];

// 2. Send it on your channel, then tell Wilson
const sent = await myChannel.send(card.recipient, card.message);
await fetch(`${BASE}/api/agent/v1/recommendations/${card.id}/outcome`, {
  method: "POST",
  headers: json,
  body: JSON.stringify({
    outcome: "sent",
    channel: "whatsapp",
    body: card.message.body,
    external_message_id: sent.id,
  }),
});

// 3. When they answer, hand the reply back
await fetch(`${BASE}/api/agent/v1/replies`, {
  method: "POST",
  headers: json,
  body: JSON.stringify({
    recommendation_id: card.id,
    channel: "whatsapp",
    body: "Thanks! Thursday works.",
    external_message_id: "wamid.def456",
  }),
});
import os, requests

BASE = "https://app.meetwilson.ai"
AUTH = {"Authorization": f"Bearer {os.environ['WILSON_KEY']}"}

# 1. Pull today's recommendations
r = requests.get(f"{BASE}/api/agent/v1/recommendations", headers=AUTH, timeout=30)
r.raise_for_status()
card = r.json()["recommendations"][0]

# 2. Send it on your channel, then tell Wilson
sent = my_channel.send(card["recipient"], card["message"])
requests.post(
    f"{BASE}/api/agent/v1/recommendations/{card['id']}/outcome",
    headers=AUTH,
    json={
        "outcome": "sent",
        "channel": "whatsapp",
        "body": card["message"]["body"],
        "external_message_id": sent.id,
    },
    timeout=30,
)

# 3. When they answer, hand the reply back
requests.post(
    f"{BASE}/api/agent/v1/replies",
    headers=AUTH,
    json={
        "recommendation_id": card["id"],
        "channel": "whatsapp",
        "body": "Thanks! Thursday works.",
        "external_message_id": "wamid.def456",
    },
    timeout=30,
)

Authentication #

Every request carries a per-workspace key in the Authorization header. There is no OAuth flow and no refresh step.

Creating a key

The workspace owner creates keys in Wilson under Settings › CRM › Agent bridge. The key is shown once, at creation. Only a SHA-256 hash is stored, so nobody, including us, can read it back to you. Store it the way you would a password.

Give each agent its own key and label it for what it does, for example WhatsApp sender. Then revoking one integration never disturbs another. A workspace can hold ten active keys at a time.

What a key can reach

A key is scoped to exactly one workspace. It can read that workspace's recommendations and write outcomes and replies for its people. It cannot read another workspace, change settings, create other keys, or send anything on the owner's behalf.

Revoking

Revoke a key from the same settings card. It stops working on the next request. Revocation is not deletion: the row stays so the workspace keeps an audit trail of what existed and when it was used.

Keep keys server-side

An agent key belongs on a server, in an environment variable or a secret manager. Never ship one in browser JavaScript, a mobile app, or a public repository. Anyone holding it can read that workspace's supporter names, addresses, and drafts.

Request header
Authorization: Bearer wsk_1a2b3c4d5e6f7890abcdef1234567890abcdef12
401 · missing or bad key
{
  "error": "missing_key",
  "message": "Pass an agent API key as `Authorization: Bearer wsk_...`. Keys are created in Wilson under Settings > CRM."
}

How Wilson chooses #

The default list, status=live, is the same eligible pool Wilson's own Today page composes from. It is not a raw table dump, and several people are deliberately absent from it:

  • The workspace's own team. Colleagues and board members marked as staff are never cultivation targets.
  • People marked skip. The owner has said not to work this relationship.
  • Snoozed people. Someone parked until a future date stays parked. Booking-cycle cards are the one exception, because a dated event is a fact on the calendar rather than a cadence you can postpone.
  • Internal chores. Cards that mean "look at this page" carry no draft and are not messages to send.
  • Duplicates. If one human is held as more than one record, they appear once, with their highest-ranked card. One human, one message.
  • Anything carrying a currency amount. Some workspaces are under terms that forbid amounts leaving the app, so those cards are withheld.

Nothing is dropped silently. Every withheld row is counted in the response, so you can always reconcile what you received against what existed.

Ordering

Live rows are sorted by effective_rank, the page's own ordering key, which folds in age decay, how long it has been since the owner last touched that person, and booking-cycle rules. Wilson's Today page seats a curated four to eight of the pool per day. The bridge hands you the whole ranked pool and lets your agent decide how many to act on. Sending from the top is the closest match to what a person would have seen.

History

acted, dismissed and expired are a record rather than a to-do list. They come back newest first, one row per card, uncollapsed, with effective_rank: null. status=all returns the live rows first, then history. A pending card that is held back, expired, or addressed to a snoozed person is never returned as actionable under any filter.

Recommendations expire

A card is good for about five days (expires_at). Do not queue them for later. Pull fresh before you send, and report outcomes promptly, so Wilson's picture of what has been said stays accurate.

The recommendation object #

One object, three parts: what Wilson is suggesting, who it is for, and the message itself.

idstring · uuid

The recommendation's id. Pass it back when you report an outcome or import a reply.

kindstring

Why this card exists, for example reconnect or event_prep. See action kinds.

statusstring

pending for live rows. History rows carry acted, dismissed or expired.

whystring · nullable

The human reason this is timely, in one sentence. Useful for logs and for a person reviewing the queue.

effective_ranknumber · nullable

Wilson's ordering key for live rows, highest first. null on history rows. rank_score is the raw score the generator assigned, before decay and touch debt.

effort_minutesnumber · nullable

Roughly how long this takes a person. Handy for budgeting a daily batch.

expires_atstring · ISO 8601

After this, the card is no longer actionable. created_at and act_on_after are also present.

recipientobject

Who the message is for.

name, first_name, last_name, company, title, role, warmth_stage describe the person. contact_id and person_id identify them inside Wilson. email and phone are how you reach them, and either may be null. preferred_channel is their stated preference when Wilson has one.

messageobject

What to send.

body is the complete message, greeting to sign-off, already in the owner's voice. Send it as-is or lightly adapted to the channel. subject is set for email-shaped cards. suggested_channel is the person's stated preference, or else whatever their contact details support, email first and then sms.

open_in_wilsonstring · url

Deep link to this person in the app, for a human who wants to look before sending.

A row with no email and no phone is still valid

Wilson recommends the relationship, not the transport. Some people are best reached in person or through a mutual contact. Skip those rows, or route them however your agent can.

Recommendation · object
{
  "id": "6b2e4f10-3c9a-4d21-9f77-1a0b8c2d3e45",
  "kind": "reconnect",
  "status": "pending",
  "rank_score": 78,
  "effective_rank": 81.4,
  "why": "You have not spoken since the spring gala and she just published a new piece.",
  "effort_minutes": 5,
  "created_at": "2026-09-01T11:15:00.000Z",
  "expires_at": "2026-09-06T11:15:00.000Z",
  "act_on_after": null,
  "recipient": {
    "contact_id": "9f1ca7b2-0e44-4c8d-b3a1-77aa20c4e918",
    "person_id": "77aa1c30-5b62-4e09-8d77-c2f4a9b10e33",
    "name": "Jane Rivera",
    "first_name": "Jane",
    "last_name": "Rivera",
    "email": "jane@example.org",
    "phone": "+1 555 010 1234",
    "preferred_channel": "whatsapp",
    "company": "Rivera Consulting",
    "title": "Principal",
    "role": "donor",
    "warmth_stage": "engaged"
  },
  "message": {
    "subject": "Your new piece",
    "body": "Hi Jane,\n\nI just read your new piece ...\n\nColin",
    "suggested_channel": "whatsapp"
  },
  "open_in_wilson": "https://app.meetwilson.ai/relationships?connector=9f1ca7b2"
}

Message text policy #

Whether Wilson keeps the words of imported replies and echoed sends is the workspace owner's decision, not the calling agent's. The switch is in Settings › CRM › Agent bridge, labelled "Keep message text from the bridge". It is on by default, because the words come from your own agent about your own conversations, and keeping them is what lets the next draft build on what was actually said.

SettingWhat Wilson storesResponse
On
default
The message text lands on the person's timeline, where Wilson's drafters read it when writing to them next. "stored": "full"
Off That a message went out or a reply came in, on which channel, with its subject line. Nothing a drafter could quote. A body you send is discarded on the server. "stored": "metadata_only"
"stored_reason": "workspace_policy"

Workspaces whose agreement keeps message content out of Wilson are set to off, and any owner can switch it off at any time. Your agent can also tighten a single call with "metadata_only": true without changing the workspace setting.

Read stored on every response rather than assuming. It tells you what Wilson actually kept.

This never changes how Wilson treats a mailbox it syncs itself

Connected inboxes stay metadata only, whatever this setting says. The policy governs the bridge, which is the one path where message text can arrive from outside.

Idempotency #

Both write endpoints are safe to retry. Pass external_message_id, your provider's own id for the message, and Wilson will recognise a repeat instead of logging it twice.

  • Reporting an outcome twice, or racing a person acting in the app, answers already_finalized: true with the status the card actually has. That is not an error.
  • Importing the same reply twice answers deduped: true.
  • Send and reply ids live in separate namespaces, so reusing one provider message id for both a send and its reply is fine.

Without an external_message_id, Wilson still tries: it treats the same words with the same received_at as a retry, and when received_at is omitted too, the same words on the same UTC day. That is a fallback, not a substitute. Send the id whenever your channel gives you one.

200 · outcome reported twice
{
  "id": "6b2e4f10-3c9a-4d21-9f77-1a0b8c2d3e45",
  "status": "acted",
  "already_finalized": true
}
200 · reply imported twice
{
  "ok": true,
  "deduped": true,
  "event_id": "3d787837-51a0-460d-8656-a47c54444cbb",
  "matched": { "contact_id": "9f1ca7b2", "prospect_id": null, "name": "Jane Rivera" },
  "stored": "full"
}

Rate limits and retries #

240 requests per minute per key. Over that, Wilson answers 429 with retryable: true. The bridge is built for a daily batch of tens of messages, not a firehose, and that ceiling is far above normal use.

Any response carrying "retryable": true is safe to try again: back off and repeat the identical request, including its external_message_id. A 503 workspace_unavailable means Wilson could not reach its own database for a moment and is the clearest example.

A 4xx without retryable will fail the same way every time. Fix the request instead of repeating it.

Suggested loop

Pull once per working morning. Send in a small batch. Report each outcome as it happens rather than at the end, so a crash halfway through cannot lose the record of what already went out.

Pull recommendations #

GET /api/agent/v1/recommendations

Returns the ranked list of communications Wilson recommends today, each with its recipient and a ready-to-send draft. This is the only endpoint most integrations poll.

Query parameters

ParameterValuesDefault
statusoptional live, acted, dismissed, expired, all. live is the actionable pool. The rest are history. live
kindoptional Comma-separated action kinds to include, for example reconnect,event_prep. all
limitoptional Whole number, 1 to 200. Applied after ranking and filtering. 60
formatoptional json or csv. See CSV export. json

Response envelope

Alongside recommendations, the response accounts for everything it withheld, so you can reconcile a short list against the pool it came from.

FieldMeaning
countHow many recommendations came back.
amount_filteredWithheld because the card text carries a currency amount.
people_filteredWithheld because the person is on the workspace's own team, or marked skip.
snoozed_filteredWithheld because the person is parked until a future date.
unsurfaced_filteredWithheld because the card is not actionable yet, or is past its expiry.
duplicates_collapsedExtra cards for a human who already appears higher in the list.
window_truncatedtrue in the rare case a pool exceeds what one export can read.

See how Wilson chooses for what each filter means, and the recommendation object for the shape of each row.

curl -s "https://app.meetwilson.ai/api/agent/v1/recommendations?limit=10" \
  -H "Authorization: Bearer $WILSON_KEY"
const res = await fetch(
  "https://app.meetwilson.ai/api/agent/v1/recommendations?limit=10",
  { headers: { Authorization: `Bearer ${process.env.WILSON_KEY}` } },
);
if (!res.ok) throw new Error(`Wilson: ${res.status}`);
const { recommendations, count } = await res.json();
import os, requests

r = requests.get(
    "https://app.meetwilson.ai/api/agent/v1/recommendations",
    params={"limit": 10},
    headers={"Authorization": f"Bearer {os.environ['WILSON_KEY']}"},
    timeout=30,
)
r.raise_for_status()
recommendations = r.json()["recommendations"]
200 · application/json
{
  "workspace": { "slug": "acme", "company_name": "Acme Foundation" },
  "generated_at": "2026-09-06T16:00:00.000Z",
  "status_filter": "live",
  "count": 4,
  "amount_filtered": 0,
  "people_filtered": 1,
  "snoozed_filtered": 0,
  "unsurfaced_filtered": 0,
  "duplicates_collapsed": 0,
  "window_truncated": false,
  "recommendations": [
    {
      "id": "6b2e4f10-3c9a-4d21-9f77-1a0b8c2d3e45",
      "kind": "reconnect",
      "status": "pending",
      "effective_rank": 81.4,
      "why": "You have not spoken since the spring gala and she just published a new piece.",
      "recipient": {
        "name": "Jane Rivera",
        "email": "jane@example.org",
        "phone": "+1 555 010 1234",
        "preferred_channel": "whatsapp"
      },
      "message": {
        "subject": "Your new piece",
        "body": "Hi Jane,\n\nI just read your new piece ...\n\nColin",
        "suggested_channel": "whatsapp"
      }
    }
  ]
}

Report the outcome of a send #

POST /api/agent/v1/recommendations/{id}/outcome

Tell Wilson what you did with a recommendation. This is what turns a suggestion into a recorded touch, so the person's cultivation clock moves and the card leaves the queue.

Body

FieldDescription
outcomerequired sent or skipped.
channelrequired for sent How you delivered it. See channels.
bodyoptional What you actually sent, if you edited the draft. Echo it when you can: it is logged as the owner's outbound message, which is what lets the next draft build on it. Subject to the message text policy.
subjectoptionalThe subject line you used, for email-shaped sends.
sent_atoptional ISO 8601 with a timezone and a real calendar date, for example 2026-09-06T14:00:00Z. Omit it to mean now. It cannot be earlier than the recommendation's created_at.
external_message_idoptional Your provider's message id. Makes the write idempotent.
skip_reasonoptional For a skip: not_relevant, wrong_person, bad_timing, or already_handled. This is how Wilson learns which recommendations are worth making.

What changes in Wilson

The card is marked acted or dismissed. For a send, a touch lands on the person's timeline, their cultivation clock moves forward, and the outcome feeds the same analytics as an act inside the app. A skip records the reason and touches nothing else.

The response's stored says what landed: full when your echoed words were kept, metadata_only when only the fact of the send was recorded, and none when no timeline row was written at all, as with a skip.

Report it even when you skip

A skipped card with a reason is more useful than silence. Cards nobody reports just sit in the queue until they expire, and Wilson learns nothing about why.

curl -s -X POST \
  "https://app.meetwilson.ai/api/agent/v1/recommendations/$CARD_ID/outcome" \
  -H "Authorization: Bearer $WILSON_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "outcome": "sent",
    "channel": "whatsapp",
    "body": "Hi Jane, I just read your new piece ...",
    "sent_at": "2026-09-06T16:03:00Z",
    "external_message_id": "wamid.abc123"
  }'
await fetch(
  `https://app.meetwilson.ai/api/agent/v1/recommendations/${cardId}/outcome`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WILSON_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      outcome: "sent",
      channel: "whatsapp",
      body: "Hi Jane, I just read your new piece ...",
      external_message_id: "wamid.abc123",
    }),
  },
);
requests.post(
    f"https://app.meetwilson.ai/api/agent/v1/recommendations/{card_id}/outcome",
    headers={"Authorization": f"Bearer {os.environ['WILSON_KEY']}"},
    json={
        "outcome": "sent",
        "channel": "whatsapp",
        "body": "Hi Jane, I just read your new piece ...",
        "external_message_id": "wamid.abc123",
    },
    timeout=30,
).raise_for_status()
200 · reported
{
  "id": "6b2e4f10-3c9a-4d21-9f77-1a0b8c2d3e45",
  "status": "acted",
  "event_id": "910f5600-ef8d-43e7-aee9-93e314c491a3",
  "stored": "full"
}
Skip · request body
{ "outcome": "skipped", "skip_reason": "bad_timing" }

Import a reply #

POST /api/agent/v1/replies

Hand Wilson what the person said back. This is the half that makes the loop worth closing: the reply lands on their timeline, where Wilson's drafters read conversation history before writing to them again.

Identifying who replied

Give Wilson the first of these that you have:

FieldWhen to use it
recommendation_idBest. The card the reply answers, so Wilson never has to guess.
contact_idThe relationship record's id, from a recommendation's recipient.
prospect_idThe pipeline record's id, if that is what you hold.
emailMatched case-insensitively against the workspace's people.

Several records holding one address are treated as one person unless their names contradict each other. A shared first or last name, or a variant such as "Beth" and "Elizabeth Howard", is the same person. Two unrelated names on a shared inbox are not, and that returns 409 ambiguous_person with candidates, so resend with contact_id. If nobody matches you get 404 person_not_found: add the person in Wilson first.

Body

FieldDescription
channelrequiredHow the reply arrived. See channels.
bodyrequiredThe reply text. Optional only when you pass metadata_only.
fromoptionalDisplay name or address, recorded in the note's header.
subjectoptionalSubject line, for email-shaped replies.
received_atoptionalISO 8601 with a timezone. Omit it to mean now.
external_message_idoptionalYour provider's message id. Makes the import idempotent.
metadata_onlyoptionaltrue logs that a reply came without its words, tightening a single call regardless of the workspace setting.

What changes in Wilson

A reply event lands on the person's timeline, where drafters read conversation history and the ask-readiness gate looks for an answer. If the person is also in the pipeline, their last-contacted clock advances, forward only, so importing an old conversation never rewinds it, and a newly identified prospect moves to reached out. Replies do not count as a touch by the owner, because a reply is them contacting you.

Do not double-feed email

If the workspace's Gmail is connected, Wilson already logs email with the people it knows. Import replies only for the channels Wilson cannot see: WhatsApp, SMS, LinkedIn, a separate sending address, and the like.

curl -s -X POST "https://app.meetwilson.ai/api/agent/v1/replies" \
  -H "Authorization: Bearer $WILSON_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recommendation_id": "6b2e4f10-3c9a-4d21-9f77-1a0b8c2d3e45",
    "channel": "whatsapp",
    "body": "So glad you saw it! Would love to catch up, how is Thursday?",
    "from": "Jane Rivera",
    "received_at": "2026-09-07T09:14:00Z",
    "external_message_id": "wamid.def456"
  }'
await fetch("https://app.meetwilson.ai/api/agent/v1/replies", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.WILSON_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    recommendation_id: cardId,
    channel: "whatsapp",
    body: "So glad you saw it! Would love to catch up, how is Thursday?",
    from: "Jane Rivera",
    external_message_id: "wamid.def456",
  }),
});
requests.post(
    "https://app.meetwilson.ai/api/agent/v1/replies",
    headers={"Authorization": f"Bearer {os.environ['WILSON_KEY']}"},
    json={
        "recommendation_id": card_id,
        "channel": "whatsapp",
        "body": "So glad you saw it! Would love to catch up, how is Thursday?",
        "from": "Jane Rivera",
        "external_message_id": "wamid.def456",
    },
    timeout=30,
).raise_for_status()
201 · logged
{
  "ok": true,
  "event_id": "f41c2564-689a-4f01-b2e6-0b7ea5e201fc",
  "matched": {
    "contact_id": "9f1ca7b2-0e44-4c8d-b3a1-77aa20c4e918",
    "prospect_id": null,
    "name": "Jane Rivera"
  },
  "stored": "full"
}
409 · two people, one address
{
  "error": "ambiguous_person",
  "message": "More than one person in this workspace has that email. Pass contact_id or prospect_id instead.",
  "candidates": [
    { "contact_id": "1c6caad1", "name": "Sarah Kim" },
    { "contact_id": "7cf5d5fe", "name": "Tom Park" }
  ]
}

Action kinds #

Every recommendation carries a kind, which is Wilson's reason for raising it. Filter with the kind parameter when your agent only handles some of them, for example a bot that only sends congratulations.

KindWhat it means
reconnectIt has been too long since this relationship was tended.
contextual_congratsSomething good happened to them that is worth acknowledging.
job_changeThey moved roles, which is a natural moment to reach out.
lapse_riskA supporter is drifting away and is worth catching now.
gift_anniversaryThe anniversary of something they did for you.
specific_favorThere is a concrete favour you could do for them.
give_backThey helped you, and there is a way to return it.
amplifyTheir work is worth boosting publicly. The draft may suit a post rather than a message.
intro_opportunityA warm path to someone you want to meet runs through them.
connection_brokerYou are well placed to introduce them to someone.
cross_connectTwo people in the network should know each other.
event_prepA booked event is coming up.
afterglowThe event happened, and a thank-you is due.
referral_askA good moment to ask who else they know.
rebook_nudgeTime to ask about doing it again.

The last four are the booking cycle, and appear only in workspaces that book dated engagements. Internal chores that carry no draft are never exported, so there is no kind for them here.

Channels #

The channel you pass when reporting a send or importing a reply. Wilson does not deliver anything itself, so this is a record of how you did it.

ValueValueValue
emailwhatsappsms
imessageslacklinkedin
phonein_personother

A recommendation's suggested_channel tells you where Wilson would start. You are free to send somewhere else, as long as you report where it actually went.

Errors #

Errors are JSON, with a stable error code your agent can branch on and a message written for a human reading logs. Codes carrying "retryable": true are worth trying again after a backoff. Everything else needs the request fixed.

StatusCodeWhat to do
401missing_keyNo key on the request. Add the Authorization header.
401invalid_keyWilson does not recognise this key. Check for a copy-paste error, or create a new one.
401key_revokedThe owner revoked it. Ask for a new key.
403workspace_inactiveThe workspace behind the key is not active. Contact the owner.
400invalid_statusUnknown status filter. Use one of the five documented values.
400invalid_outcomeoutcome must be sent or skipped.
400invalid_channelUnknown channel. See channels.
400invalid_timestampNot ISO 8601 with a timezone, not a real calendar date, in the future, or before the card existed.
400invalid_emailThe email you passed is not shaped like an address.
400missing_bodyA reply needs body, or metadata_only: true.
400missing_personSay who replied: a recommendation, contact, prospect, or email.
400missing_dedupe_keyA metadata-only reply needs external_message_id or received_at, or retries could not be told from new replies.
404recommendation_not_foundNo such card in this workspace, or the id is malformed.
404person_not_foundNobody in this workspace matches. Add them in Wilson first.
409ambiguous_personTwo different people share that address. Resend with contact_id, using the returned candidates.
429rate_limitedOver 240 requests a minute. Back off and retry.
500export_failed
update_failed
import_failed
Wilson could not complete the write. Retry the identical request, keeping your external_message_id.
503workspace_unavailableA transient problem reaching the database. Back off and retry.

Two things that look like errors and are not

already_finalized: true means the card was already resolved, by an earlier retry or by a person in the app. deduped: true means you imported that reply already. Both come back 200, and both mean your work is safely recorded.

Handling them

Branch on error, and let retryable decide whether to try again.

Error · shape
{
  "error": "invalid_timestamp",
  "message": "sent_at must be an ISO-8601 timestamp with a timezone, e.g. 2026-09-05T14:00:00Z"
}
Retryable · shape
{
  "error": "workspace_unavailable",
  "retryable": true
}
# Retry only when the response says it is safe to.
for attempt in 1 2 3 4; do
  body=$(curl -s -w '\n%{http_code}' "$BASE/api/agent/v1/recommendations" \
    -H "Authorization: Bearer $WILSON_KEY")
  code=$(printf '%s' "$body" | tail -n1)
  [ "$code" = "200" ] && break
  printf '%s' "$body" | grep -q '"retryable":true' || break
  sleep $((attempt * attempt))
done
async function wilson(path, init, tries = 4) {
  for (let attempt = 1; attempt <= tries; attempt++) {
    const res = await fetch(`https://app.meetwilson.ai${path}`, init);
    if (res.ok) return res.json();

    const err = await res.json().catch(() => ({}));
    // Retryable: back off and repeat the identical request.
    if (err.retryable && attempt < tries) {
      await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
      continue;
    }
    throw new Error(`${res.status} ${err.error}: ${err.message ?? ""}`);
  }
}
import time, requests

def wilson(method, path, tries=4, **kw):
    for attempt in range(1, tries + 1):
        r = requests.request(
            method, f"https://app.meetwilson.ai{path}",
            headers={"Authorization": f"Bearer {os.environ['WILSON_KEY']}"},
            timeout=30, **kw,
        )
        if r.ok:
            return r.json()
        err = r.json() if "json" in r.headers.get("content-type", "") else {}
        # Retryable: back off and repeat the identical request.
        if err.get("retryable") and attempt < tries:
            time.sleep(2 ** attempt * 0.5)
            continue
        raise RuntimeError(f"{r.status_code} {err.get('error')}: {err.get('message', '')}")

CSV export #

Add format=csv to the recommendations endpoint for the same list as a spreadsheet, for a person working a queue by hand or for a tool that speaks CSV rather than JSON.

The file is RFC 4180, UTF-8 with a byte-order mark so Excel reads accented names correctly, and CSV libraries strip it. Fields containing commas, quotes or newlines are quoted, and message bodies keep their real line breaks inside those quotes.

Columns

recommendation_id, kind, status, rank_score, effective_rank, why, recipient_name, recipient_email, recipient_phone, preferred_channel, suggested_channel, recipient_company, recipient_title, subject, body, effort_minutes, created_at, expires_at, source_url, open_in_wilson

Spreadsheet formula guard

Free-text columns are imported data, so a value starting with =, +, - or @ is prefixed with an apostrophe and cannot execute when the file is opened in Excel or Sheets. Ids, numbers, dates, URLs and recipient_phone are never altered, so a phone number keeps its leading +. Use JSON when you need byte-exact text.

Without a key

A signed-in person can download the same export from Settings › CRM › Agent bridge, no key required. That is the fastest way to see what the bridge would hand your agent before you build anything.

Download · today's list
curl -s "https://app.meetwilson.ai/api/agent/v1/recommendations?format=csv" \
  -H "Authorization: Bearer $WILSON_KEY" \
  -o wilson-recommendations.csv

Questions #

Building on the bridge and something here is wrong, missing, or harder than it should be? Write to colin@colinrobertson.dev or grab thirty minutes. There is also a PDF of this reference if you would rather send one file to a developer.