Appearance
Testing your integration
Building a connector means writing code for cases a real character almost never hands you on demand: a photo bubble, a 402, a sealed conversation, an ignored duplicate, an outreach that opens versus one that quietly doesn't. Waiting for a live persona to produce each of those — on billed turns, against real leads — is not a test loop.
So every endpoint takes a mode.
| Mode | Runs the model? | Cost | Same answer every time? | Use it for |
|---|---|---|---|---|
live (default) | yes | billed | no | Production. |
mock | no | free | yes | Testing your code — bubble delivery, image attaching, error handling, retries. This is the one your CI runs. |
sandbox | yes, the real pipeline | billed at cost | no | The pre-flight before go-live — does she actually open well, was my photo really seen, does the funnel move. |
Set it once on the client and nothing else in your code changes:
python
ft = FluidTalk(token="ftc_...", mode="mock")typescript
const ft = new FluidTalk({ token: "ftc_...", mode: "mock" });bash
curl ... -H "X-FluidTalk-Mode: mock"Or per call, as a mode field in the request body — the body field wins over the header.
Both test modes require a test_ handle
mock and sandbox only accept a handle starting with test_. Anything else is a 400.
That is deliberate. It means a mode accidentally left switched on in production cannot answer a real lead with a fixture — the call fails loudly instead. test_ is reserved for this; don't use it for a real lead.
A typo'd mode (sandbx) is a 422, never a silent fall back to live. Guessing live for a typo would bill a turn you thought was a test.
mock still authenticates for real
It is not an offline stub. Your token is verified, the platform binding is resolved, and rate limits apply — so a bad token still 401s and an unbound platform still 404s in mock. A fixture returned on a broken token would let you "verify" auth you haven't actually got working, which is exactly the kind of false green this surface exists to remove.
What mock skips is the model call, the database writes and the charge. Nothing else.
Start here: GET /self-test
One free call, no model, no charge. Run it before you write anything else.
bash
curl https://api-talk.fluidvip.com/api/v1/characters/self-test \
-H "X-Connector-Token: ftc_..."json
{
"data": {
"character": { "name": "Ava", "id": "..." },
"bindings": [{
"platform": "instagram",
"enabled": true,
"workflow": "IG Flow",
"comments_enabled": true,
"engage_enabled": true,
"max_engagements_per_post": 2,
"has_outreach_node": true,
"fireable_event_ids": ["story_reaction", "new_follower", "outreach"],
"entry_points": [ ... ]
}],
"photos": { "total": 12, "organized": 12, "by_tier": { "tier_1": 6, "tier_2": 4, "tier_3": 2 } },
"settings": { "ignore_duplicate_leads": false, "separate_sessions_per_account": false },
"limits": { "requests_per_min": 300, "generations_per_min_per_action": 60, "enforced": true },
"checks": [
{ "check": "token_valid", "ok": true, "detail": "Authenticated as character 'Ava'." },
{ "check": "platform_bound", "ok": true, "detail": "1 platform binding(s): instagram" },
{ "check": "triggers_fireable", "ok": true, "detail": "Fireable event_ids: story_reaction, ..." },
{ "check": "photo_vault_sendable", "ok": true, "detail": "12 photo(s) in the vault ..." }
],
"ok": true
}
}It answers the two questions that are invisible on /chat and cause most "the API doesn't work" reports:
fireable_event_ids— the only place these are discoverable. A/triggerscall with anevent_idthat isn't in this list is a no-op that returns200(see below).photo_vault_sendable— if the vault is empty or unorganized, the character will never emit a photo bubble no matter what the lead asks. Without this check that is indistinguishable from a broken integration.
Test handles pick the branch
In mock, the lead handle decides what comes back — like Stripe's test card numbers. No setup, no state, no waiting.
POST /chat
handle | What you get |
|---|---|
test_reply | 3 normal text bubbles with delays |
test_photo | a text bubble plus a photo bubble carrying a real, downloadable image_url |
test_photos | two different photo bubbles — catches media caching/dedup bugs |
test_vision | a reply with the vision block populated |
test_ignored | ignored: true, ignore_reason: "lead_claimed_by_other_account" |
test_duplicate | ignored: true, ignore_reason: "duplicate_message" |
test_sealed | bubbles: [] — conversation closed, send nothing |
test_402 | HTTP 402 payment_required |
test_429 | HTTP 429 with a Retry-After header |
test_500 | HTTP 500 internal_error |
anything else test_* | the default reply fixture |
POST /triggers — outreach and custom event triggers
Two different node types fire this endpoint, and they behave differently:
- the built-in Outreach node (
event_id: "outreach", a reserved key) — a cold open you decided to make. On a lead who already has a live conversation it is a no-op, because there is nothing to open. - a custom Event Trigger (your own
event_id) — something happened. It cold-opens when there is no chat, and folds into the chat as the next turn when there is.
handle | What you get |
|---|---|
test_outreach | Outreach node, cold — opened: true plus opener bubbles |
test_outreach_warm | Outreach node on a live chat — opened: false, ignored: true (the no-op) |
test_trigger_cold | Custom Event Trigger, cold — opened: true, and your context is echoed into the opener |
test_warm_event | Custom Event Trigger on a live chat — opened: false with bubbles |
test_trigger_photo | cold open informed by a profile screenshot — vision.seen: true |
test_trigger_photo_unseen | screenshot could not be fetched — vision.seen: false, generic opener, still succeeds |
test_warm_event_photo | vision.reason: "not_used_warm_conversation" |
test_trigger_unknown | ignore_reason: "event_not_configured" |
test_deduped | deduped: true — the idempotency replay |
test_paused | paused: true, pause_reason: "plan_limit" |
Opening on a profile screenshot
/triggers takes an image_url — a screenshot of the lead's profile, so the cold open can reference something specific instead of "hey 🙂". It works for the Outreach node and for custom Event Triggers alike, because both cold-open through the same path.
bash
# 1. no public URL for your screenshot? upload the bytes, get a FluidCloud link back
URL=$(curl -s -X POST https://api-talk.fluidvip.com/api/v1/characters/inbound-media \
-H "X-Connector-Token: ftc_..." -H "Content-Type: application/json" \
-d "{\"platform\":\"instagram\",\"data_b64\":\"$(base64 -w0 profile.png)\",\"filename\":\"profile.png\"}" \
| python -c "import sys,json; print(json.load(sys.stdin)['data']['url'])")
# 2. open on it
curl -X POST https://api-talk.fluidvip.com/api/v1/characters/triggers \
-H "X-Connector-Token: ftc_..." -H "Content-Type: application/json" \
-d "{\"platform\":\"instagram\",\"handle\":\"mark\",\"event_id\":\"outreach\",
\"external_event_id\":\"lead-42\",\"image_url\":\"$URL\"}"json
{ "data": {
"opened": true,
"bubbles": [{ "text": "okay the dog in your third pic has my whole heart", "delay_ms": 900, "image_url": null }],
"vision": { "supplied": true, "seen": true,
"description": "vet tech in Austin, three photos with a golden retriever", "reason": null }
} }Always check vision.seen
A screenshot we cannot fetch does not fail the call — you still get an opener, just a generic one. seen: false with reason: "vision_failed" means the model provider could not fetch your URL. See the Wikimedia example below.
The screenshot is used on a cold open only. If a conversation is already live you get vision.reason: "not_used_warm_conversation" — reported rather than silently dropped, because she already knows this lead.
POST /events
handle | What you get |
|---|---|
test_deduped | deduped: true — the idempotency replay |
anything else test_* | ok: true with the lifecycle flip reported but not applied |
Mock this one especially
/events is the only endpoint whose live effect you cannot undo from the API — it flips a Person's lifecycle stage. Test your purchase-webhook wiring in mock, where nothing is written.
POST /followups/sweep and GET /followups
In mock, a sweep always queues one follow-up and it is immediately pullable — no dormancy wait. Acking a mock id always succeeds. This is the whole pull → deliver → ack loop in three calls, instead of waiting days for a real lead to go quiet.
POST /comments and POST /comments/reply
Comments have no handle, so the fixture key is the post_ref — the post is what a comment conversation hangs off, the way a lead is for a DM. The same test_ rule applies to it.
Check this before you debug anything else
comments.enabled defaults to true, so a character with a bound workflow comments without you configuring anything. If someone has switched Comments off for that workflow, the endpoint fail-closes with 200 and every call returns:
json
{ "ok": true, "ignored": "comments not configured",
"ignore_reason": "comments_disabled", "comment": null,
"explain": "Comments are switched OFF for workflow 'IG Flow'… — comments are on by default, so this workflow has been set that way deliberately. Turn Comments back on in the workflow's settings; nothing you send to this endpoint will generate anything until you do." }GET /self-test reports comments_enabled per binding so you can check it before writing any code.
POST /comments — keyed by post_ref:
post_ref | What you get |
|---|---|
test_anything | a normal top-level comment |
test_vision | a comment on a post with images — vision.seen: true |
test_vision_unseen | the post's image couldn't be read — comment still returned, seen: false |
test_known | the post author is a Person she already knows from DMs — recognized_lead: true, aware.cross_surface |
test_disabled | the comments_disabled branch above |
test_paused | paused: true |
POST /comments/reply — every stop rule returns ok: true with reply: null:
post_ref | decision / reason |
|---|---|
test_anything | comment_reply — a normal threaded reply |
test_dm | drive_to_dm_nudge — warm enough to nudge them to DMs. A different decision, and never contains a link |
test_once | skip · mode=comment_once — she comments but never replies |
test_hostile | bow_out · hostile/bot-accusation — post nothing, thread is BOWED_OUT |
test_maxed | skip · max_replies_per_thread |
test_stranger | skip · reply_to_strangers=off |
test_disabled | comments_disabled |
Branch on decision and reason, never on explain.
Every mock comment and reply with text carries the same length fields as a live one: max_chars is the max_chars you sent, lowered to the platform's ceiling (so 1000 on TikTok comes back as 150), or the default of 150 when you sent none, and truncated is always false.
POST /comments/engage
The third comment motion — joining a thread between other people — and the one whose normal outcome is often nothing. The fixtures make the quiet branches as reachable as the loud one, because a connector that treats an abstention as a failure will look broken in production.
post_ref | What you get |
|---|---|
test_anything | comment_reply — a reply to the first comment you sent, so target_comment_ref is one you can match against your own data |
test_engage_skip | skip · no_target_selected — the character read the thread and had no business in it. Post nothing. This is a good outcome, and it is not rare. |
test_engage_hostile | bow_out · hostile/bot-accusation — post nothing and stop engaging here |
test_engage_deep | skip · thread_too_deep — more comments than engage_max_thread_depth |
test_disabled | the comments_disabled branch above |
Send a real thread even in mock — the fixture reads it back, so the reply you get is threaded against your own comment_refs. GET /self-test reports engage_enabled and max_engagements_per_post per binding, so you can tell "switched off" from "abstained" before you start debugging the skips.
POST /posts
A post has no handle and no post_ref — the community is what it hangs off — so the fixture key is community.ref, and the same test_ rule applies to it. A mode: "mock" call naming a real subreddit is a 400, not a fixture.
This surface needs the fixtures more than the others
A refusal is the normal answer. A quiet community correctly returns no_suitable_post_found, so an integrator who has only ever seen that branch cannot tell it from a broken key, a disabled workflow, or a character in the wrong room. Every branch below is reachable on demand — including test_failed, which is the one that means we broke rather than the community being quiet.
community.ref | What you get |
|---|---|
test_anything | post — a full post: title, body, flair, plus the angle and the evidence it came from |
test_none | no_suitable_post_found · gate community — the normal refusal. A 200, and billed: finding out cost real model work |
test_persona | no_suitable_post_found · gate persona — the one worth reading. Ideas cleared the floors, but the character cannot write any of them honestly. Includes would_have_to_invent |
test_asset | no_suitable_post_found · gate asset — the image does not belong in this community; refused once it was described, before the community was read |
test_asset_unseen | generation_failed with vision.seen: false — the image could not be seen, so nothing ran after it |
test_failed | generation_failed — ours, not the community's. Branch on it separately; retry is reasonable |
test_disabled | posts_disabled — free, nothing ran |
test_paused | paused — free, nothing ran |
Send an asset with any of them and the fixture also carries the vision object a live call returns (except on test_disabled and test_paused, which return before anything runs).
Send a count from 2 to 5 and test_anything returns that many distinct posts in posts (with requested and returned), post being the first; the refusals are unchanged. A count outside 1–5 is a 400, as live. See Several posts in one call.
Send always_draft: true and the refusals also carry what a live call returns: test_none two drafts (one written under the floors, one drafted for the character and turned down on review), test_persona one honest outsider draft, and test_asset draft: null with draft_unavailable.code: "asset_rejected". See Always get a draft.
Branch on decision first, then on gate. GET /self-test reports posts_enabled per binding, so you can tell "switched off" from "quiet community" before writing any code.
The two that look alike and mean opposite things are no_suitable_post_found and generation_failed: the first says the room was read and found wanting, the second says nothing was ever evaluated. Treat the first as a signal about the community and the second as a signal about us. See the Posts guide.
Did she see the post's image?
POST /comments carries the same vision block as /chat, when you send post.image_urls:
json
"vision": { "supplied": 2, "seen": true,
"description": "a beach at sunset with two people silhouetted", "reason": null }supplied is how many URLs you sent (up to 4 reach the model), so you can tell "you sent none" from "we couldn't read the ones you sent". A post image we can't fetch does not block the comment — she riffs on the caption alone and you get a blander comment with no error. seen: false is the only signal.
POST /inbound-media
In mock it returns a sample URL and stores nothing. Use it to check your upload wiring without filling the character owner's storage with test files.
POST /posts/media
Keyed by community_ref, with the same test_ rule and the same keys as POST /posts: test_disabled returns the posts_disabled no-op and test_paused the plan_limit one, both with url: null. Any other test_ ref returns the real, published Outbound 1 sample URL and stores nothing — a URL the vision model can fetch, so it also works as asset.url on a live /posts call.
Testing the photo path
This is the one you cannot reach on demand in live mode: whether she sends a photo depends on the strategist choosing a photo move and a vault photo surviving the tier, daypart, set and already-sent gates.
python
reply = ft.chat(platform="instagram", handle="test_photo", message="hey")
for b in reply.bubbles:
if b.image_url:
send_photo("test_photo", b.image_url) # ← the path you actually need to test
else:
send_dm("test_photo", b.text)A photo bubble's text is empty
{"text": "", "delay_ms": 1600, "image_url": "https://..."} — send it as a photo, not as a caption on the previous message. The test_photo fixture returns exactly this shape so you find out now rather than in production.
Test images you can use right now
You need media to build photo handling, in both directions, before you have any of your own. These are published with the docs — real files, publicly downloadable, stable URLs:
| Image | URL | Use it for |
|---|---|---|
| Inbound photo | /docs/samples/inbound-photo.png | Pass as image_url on /chat — "the lead sent you this". Exercises vision and the vision block. |
| Profile card | /docs/samples/profile-card.png | Pass as image_url on /triggers — a profile screenshot to cold-open on. |
| Outbound 1 | /docs/samples/outbound-photo-1.png | What a photo bubble points at in mock. Build your download-and-attach path against it. |
| Outbound 2 | /docs/samples/outbound-photo-2.png | A second, different file — for connectors that cache or dedup media by URL. |
| No-hook image | /docs/samples/no-hook.png | Shapes only. On /triggers it returns vision.reason: "no_usable_hook" — the fallback branch. |
GET /self-test returns these URLs too, under testing.sample_images, so you never have to look them up.
Each image says its own name inside the picture
vision.description for the inbound sample reliably mentions an orange circle and the words "inbound photo" — so you can assert on it in a test instead of eyeballing it, and a wrong-image bug is obvious at a glance.
Receiving a photo (lead → character)
python
r = ft.chat(platform="instagram", handle="test_vision",
message="what do you think?",
image_url="https://talk.fluidvip.com/docs/samples/inbound-photo.png")
assert r.vision.seen is True
print(r.vision.description) # -> mentions an orange circleRun it in sandbox for a real vision call, or in mock for a canned vision block.
Sending a photo (character → lead)
python
mock = FluidTalk(token="ftc_...", mode="mock")
reply = mock.chat(platform="instagram", handle="test_photo", message="hey")
for b in reply.bubbles:
if b.image_url:
data = requests.get(b.image_url).content # a real, downloadable file
send_photo("test_photo", data) # ← the path you need to test
else:
send_dm("test_photo", b.text)Use handle="test_photos" to get two different images in one reply — one sample will not catch a connector that caches or dedups by URL.
Cold-opening on the sample profile
python
sandbox = FluidTalk(token="ftc_...", mode="sandbox")
r = sandbox.trigger(platform="instagram", handle="test_lead", event_id="outreach",
external_event_id="lead-42",
image_url="https://talk.fluidvip.com/docs/samples/profile-card.png")
print(r.vision.description) # the hook she found
print([b.text for b in r.bubbles]) # the opener built on itMeasured against the sample card, which reads "vet tech in Austin / mum to two golden retrievers / I run marathons badly":
vision : "a vet tech in Austin with two golden retrievers and marathon running"
opener : "hey, those two golden retrievers look like a handful lol"The images are generated by
docs/api/scripts/make_samples.py, committed alongside the docs. They are fixtures, not stock photography — deliberately synthetic so their descriptions stay stable.
Did she actually see my photo?
When you pass an image_url, the reply carries a vision block. This is returned in live mode too — it is not a testing-only field.
python
reply = ft.chat(platform="instagram", handle="mark",
message="what do you think? 😏",
image_url="https://your-cdn.example.com/inbound/mark-42.jpg")
reply.vision
# { "supplied": true, "seen": true, "description": "a grey cat on a windowsill", "reason": null }
# { "supplied": true, "seen": false, "description": null, "reason": "vision_failed" }reason | Meaning |
|---|---|
null | The image was fetched and described. description is what the character "saw". |
vision_failed | The image could not be fetched or described. Almost always an image_url that isn't publicly reachable — a signed or expired platform CDN link, or something behind auth. |
no_url | No image was supplied. |
empty_description | The image was fetched but nothing usable came back. |
Check seen, don't assume it
A photo that could not be fetched does not fail the turn. The character still replies — just blandly, reacting to a photo she never saw. Without this block that is invisible: you get slightly worse conversations and no error anywhere.
If you see vision_failed, your URL isn't publicly fetchable. Upload the bytes to POST /inbound-media and pass the URL it returns.
Remember that we do not fetch the image — the model provider does. A link that works from your server, or that you can open in your own browser, may still fail here.
That is not a hypothetical. A public Wikimedia image URL — one that renders fine in any browser — comes back seen: false, because Wikimedia rejects the provider's fetcher:
json
{ "supplied": true, "seen": false, "description": null, "reason": "vision_failed" }and the character still replied, with a perfectly plausible "oh wow... that's a choice" that gives no hint she saw nothing. The same image uploaded through POST /inbound-media first came back:
json
{ "supplied": true, "seen": true,
"description": "A red circle on a white background, resembling the Japanese flag, with a neutral vibe.",
"reason": null }If in doubt, upload the bytes. A URL you control is the only kind you can be confident about — and vision.seen is how you find out either way.
Why my trigger did nothing
/triggers is fail-closed: an event_id that isn't configured fires nothing and returns 200. Six different outcomes return 200 with no bubbles, so every one of them now carries an explain string saying what happened and what to do.
json
{
"data": {
"ok": true,
"ignored": "event not configured",
"ignore_reason": "event_not_configured",
"bubbles": [],
"explain": "No entry point with event_id 'story_reactionn' exists on workflow 'IG Flow' (character 'Ava', platform 'instagram'). Fireable event ids: story_reaction, new_follower, outreach."
}
}The 200-with-nothing outcomes, all of which carry explain:
| Situation | Signal |
|---|---|
Unconfigured / typo'd event_id | ignore_reason: "event_not_configured" |
external_event_id already delivered | deduped: true |
| Outreach on a conversation that's already open | opened: false, ignored: true |
| Character over the plan's cap | paused: true |
| Lead claimed by another account | ignore_reason: "lead_claimed_by_other_account" |
Sealed conversation (/chat) | bubbles: [] |
explain is prose for a human reading logs. Branch your code on ignore_reason / deduped / opened, never on the explain text — its wording will change.
Testing outreach for real
Outreach is the hardest path in this API to exercise live, and it's worth knowing exactly why:
- Your first cold open succeeds and creates a conversation.
- Every later cold open for that lead is now the
already in conversationno-op — outreach only ever opens. - And re-sending the same
external_event_idreturnsdeduped: trueregardless.
So live, testing outreach works exactly once. Both behaviours are correct — they're what stops a retry from cold-opening the same person twice — but they make iteration impossible.
sandbox relaxes both, for its own test sessions only:
python
ft = FluidTalk(token="ftc_...", mode="sandbox")
for i in range(3):
r = ft.trigger(platform="instagram", handle="test_out",
event_id="outreach", external_event_id="same-key-every-time")
assert r.opened # a real, fresh cold open every time
print(r.bubbles)Each call runs the real opener with the real model against the real workflow, and bills at cost. Nothing about a live conversation is affected: the reseal only ever touches a session sandbox mode opened itself.
What sandbox mode does and doesn't change
sandbox | |
|---|---|
| The model, the workflow, the pipeline | Identical to live. |
| Billing | Billed at cost, same as any turn. |
| Session | Marked origin: "test" — excluded from analytics, and purgeable. |
| Proactive follow-ups | Never queued for a test_ lead. (Each rekindle is a billed generation; abandoned test leads would quietly cost you money.) |
| Duplicate-message guard | Off — re-send the same message as often as you like. |
| Trigger idempotency | Off — reuse one external_event_id. |
| Outreach on an existing test session | Reseals it first, so the cold open happens again. |
Everything else — rate limits, plan caps, dedup, the 402 when the wallet can't cover a turn — behaves exactly as it does live.
A worked example
python
from fluidtalk import FluidTalk
# 1. Wiring check — free, no model.
ft = FluidTalk(token="ftc_...")
st = ft.self_test()
assert st.ok, [c for c in st.checks if not c.ok]
# 2. Your code, against fixtures. Free, instant, deterministic — this is your CI.
mock = FluidTalk(token="ftc_...", mode="mock")
reply = mock.chat(platform="instagram", handle="test_photo", message="hey")
deliver(reply.bubbles) # exercises the photo-attach path (real file)
two = mock.chat(platform="instagram", handle="test_photos", message="hey")
deliver(two.bubbles) # two DIFFERENT images -> catches media dedup bugs
seen = mock.chat(platform="instagram", handle="test_vision", message="look",
image_url="https://talk.fluidvip.com/docs/samples/inbound-photo.png")
assert seen.vision.seen is True # exercises your inbound-photo path
shot = mock.trigger(platform="instagram", handle="test_trigger_photo", event_id="outreach",
external_event_id="e1",
image_url="https://talk.fluidvip.com/docs/samples/profile-card.png")
assert shot.opened and shot.vision.seen # exercises outreach-with-a-screenshot
sealed = mock.chat(platform="instagram", handle="test_sealed", message="hey")
assert sealed.bubbles == [] # exercises "send nothing"
try:
mock.chat(platform="instagram", handle="test_402", message="hey")
except PaymentRequiredError:
pass # exercises your top-up handling
# 3. One real end-to-end run before go-live. Billed at cost.
sandbox = FluidTalk(token="ftc_...", mode="sandbox")
r = sandbox.trigger(platform="instagram", handle="test_me",
event_id="outreach", external_event_id="preflight-1")
print(r.bubbles) # what a real lead would actually receiveSee also
- Sending & receiving DMs — the inbound loop and
image_url. - Triggers & openers — entry points and the reserved
outreachkey. - Media — uploading bytes when you have no public URL.
- Errors — the full error model.
- Billing — what a turn costs and what a
402means.