Wilson Agent Bridge
Wilson decides who to write and drafts the note. Your agent sends it, and tells Wilson what came back.
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.
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.
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.
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.
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.
- Pull.
GET /api/agent/v1/recommendationsreturns the ranked list. Each row carries the recipient and the complete draft. - Send, then report. Deliver the message on your own channel, then
POSTthe outcome so Wilson records the touch. Pass your provider's message id asexternal_message_idso a retry cannot double-log it. - Import the reply. When they answer,
POSTit 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.
Authorization: Bearer wsk_1a2b3c4d5e6f7890abcdef1234567890abcdef12
{
"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.
The recommendation's id. Pass it back when you report an outcome or import a reply.
Why this card exists, for example reconnect or event_prep. See action kinds.
pending for live rows. History rows carry acted, dismissed or expired.
The human reason this is timely, in one sentence. Useful for logs and for a person reviewing the queue.
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.
Roughly how long this takes a person. Handy for budgeting a daily batch.
After this, the card is no longer actionable. created_at and act_on_after are also present.
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.
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.
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.
{
"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.
| Setting | What Wilson stores | Response |
|---|---|---|
| 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: truewith 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.
{
"id": "6b2e4f10-3c9a-4d21-9f77-1a0b8c2d3e45",
"status": "acted",
"already_finalized": true
}
{
"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 #
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
| Parameter | Values | Default |
|---|---|---|
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.
| Field | Meaning |
|---|---|
count | How many recommendations came back. |
amount_filtered | Withheld because the card text carries a currency amount. |
people_filtered | Withheld because the person is on the workspace's own team, or marked skip. |
snoozed_filtered | Withheld because the person is parked until a future date. |
unsurfaced_filtered | Withheld because the card is not actionable yet, or is past its expiry. |
duplicates_collapsed | Extra cards for a human who already appears higher in the list. |
window_truncated | true 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"]
{
"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 #
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
| Field | Description |
|---|---|
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. |
subjectoptional | The 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()
{
"id": "6b2e4f10-3c9a-4d21-9f77-1a0b8c2d3e45",
"status": "acted",
"event_id": "910f5600-ef8d-43e7-aee9-93e314c491a3",
"stored": "full"
}
{ "outcome": "skipped", "skip_reason": "bad_timing" }
Import a reply #
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:
| Field | When to use it |
|---|---|
recommendation_id | Best. The card the reply answers, so Wilson never has to guess. |
contact_id | The relationship record's id, from a recommendation's recipient. |
prospect_id | The pipeline record's id, if that is what you hold. |
email | Matched 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
| Field | Description |
|---|---|
channelrequired | How the reply arrived. See channels. |
bodyrequired | The reply text. Optional only when you pass metadata_only. |
fromoptional | Display name or address, recorded in the note's header. |
subjectoptional | Subject line, for email-shaped replies. |
received_atoptional | ISO 8601 with a timezone. Omit it to mean now. |
external_message_idoptional | Your provider's message id. Makes the import idempotent. |
metadata_onlyoptional | true 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()
{
"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"
}
{
"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.
| Kind | What it means |
|---|---|
reconnect | It has been too long since this relationship was tended. |
contextual_congrats | Something good happened to them that is worth acknowledging. |
job_change | They moved roles, which is a natural moment to reach out. |
lapse_risk | A supporter is drifting away and is worth catching now. |
gift_anniversary | The anniversary of something they did for you. |
specific_favor | There is a concrete favour you could do for them. |
give_back | They helped you, and there is a way to return it. |
amplify | Their work is worth boosting publicly. The draft may suit a post rather than a message. |
intro_opportunity | A warm path to someone you want to meet runs through them. |
connection_broker | You are well placed to introduce them to someone. |
cross_connect | Two people in the network should know each other. |
event_prep | A booked event is coming up. |
afterglow | The event happened, and a thank-you is due. |
referral_ask | A good moment to ask who else they know. |
rebook_nudge | Time 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.
| Value | Value | Value |
|---|---|---|
email | whatsapp | sms |
imessage | slack | linkedin |
phone | in_person | other |
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.
| Status | Code | What to do |
|---|---|---|
| 401 | missing_key | No key on the request. Add the Authorization header. |
| 401 | invalid_key | Wilson does not recognise this key. Check for a copy-paste error, or create a new one. |
| 401 | key_revoked | The owner revoked it. Ask for a new key. |
| 403 | workspace_inactive | The workspace behind the key is not active. Contact the owner. |
| 400 | invalid_status | Unknown status filter. Use one of the five documented values. |
| 400 | invalid_outcome | outcome must be sent or skipped. |
| 400 | invalid_channel | Unknown channel. See channels. |
| 400 | invalid_timestamp | Not ISO 8601 with a timezone, not a real calendar date, in the future, or before the card existed. |
| 400 | invalid_email | The email you passed is not shaped like an address. |
| 400 | missing_body | A reply needs body, or metadata_only: true. |
| 400 | missing_person | Say who replied: a recommendation, contact, prospect, or email. |
| 400 | missing_dedupe_key | A metadata-only reply needs external_message_id or received_at, or retries could not be told from new replies. |
| 404 | recommendation_not_found | No such card in this workspace, or the id is malformed. |
| 404 | person_not_found | Nobody in this workspace matches. Add them in Wilson first. |
| 409 | ambiguous_person | Two different people share that address. Resend with contact_id, using the returned candidates. |
| 429 | rate_limited | Over 240 requests a minute. Back off and retry. |
| 500 | export_failedupdate_failedimport_failed | Wilson could not complete the write. Retry the identical request, keeping your external_message_id. |
| 503 | workspace_unavailable | A 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": "invalid_timestamp",
"message": "sent_at must be an ISO-8601 timestamp with a timezone, e.g. 2026-09-05T14:00:00Z"
}
{
"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_wilsonSpreadsheet 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.
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.