Appearance
Core concepts
This page is your mental model for the FluidTalk Characters API. It defines every concept your connector token touches — Character, Platform + handle, Session, the Person (cross-surface awareness), Bubbles, Triggers, Follow-ups, and Comment threads — plus the cross-cutting behaviors that tie them together: the request/response envelope, idempotency, and multi-account dedup.
Each concept maps to one or more endpoints: see Chat, Events, Triggers, Follow-ups, Comments, Comment replies, and Comment engage. New here? Start with the Quickstart.
What the API does — and what it does not. The Characters API generates a character's messages and tracks the relationship. It never touches a platform itself: reading and sending DMs, posting comments, and watching for new followers is your connector's job. How you connect to the platform is your concern. The API is the brain; you are the hands.
How the pieces fit
A character is the top of the tree. Everything else hangs off the (platform, handle) pair that identifies whom the character is talking to:
Character (your connector token — one AI persona)
└── Platform (a workflow bound per platform: instagram, twitter, reddit, …)
└── Lead = (platform, handle) (someone the character talks to)
├── Session (one DM conversation thread → bubbles)
└── Comment thread (a public comment + its threaded replies)And cutting across that tree is the headline feature — the Person:
Person (one human, one consistent persona)
├── their DMs (Sessions)
└── their comments (Comment threads)
└── … across every post, thread, and surface- A Character is your token. You authenticate as exactly one character.
- A Platform is a workflow bound to that character in the dashboard. One character can run on many platforms with the same token — you name the platform in each request body.
- A Lead is whom you talk to, identified by
(platform, handle). Re-using the same pair resumes that relationship. - A Session is one DM conversation thread; a Comment thread is one public comment plus its replies.
- A Person unifies a lead's DM and comment activity into one coherent, aware persona.
You address leads by (platform, handle), not by ids — there is no character_id field (your token is the character) and you rarely pass a session_id. The ids you do see (session_id, a follow-up id, the request_id) are opaque strings. All timestamps are ISO-8601 UTC (e.g. 2026-06-23T14:05:00Z).
Character
The AI persona — and your credential. A character is one consistent personality (say, a character named Ava) with its own voice, photos, lorebook, and per-platform funnels. Your connector token authenticates as that character: the token is the character, so there is no separate account key and no character_id to pass.
You get a character's token from its API tab in the dashboard. The account owner can show or copy it there any time (a token created before keys became viewable appears after its next use) — treat it like a password. A token looks like:
ftc_EXAMPLE_n0t-a-real-token_8f3c2a7b8e1d4056a1Send it on every request in the X-Connector-Token header (see Authentication). The same token works for every platform the character is bound to; the platform field in each request body selects which bound workflow runs.
One character, many platforms, one token. A character can also run several accounts on one platform — see Multi-account dedup.
Platform + handle
How a lead is identified. Every conversational call carries a platform (the network, e.g. "instagram", "twitter", "reddit", "fanvue", "tiktok", or any platform you run) and a handle (the lead's @username, e.g. "mark"). Together, (platform, handle) is the lead's identity.
- The platform selects which of the character's bound workflows runs and scopes the lead —
markon Instagram andmarkon Twitter are two different leads. - Re-using the same
(platform, handle)resumes that lead's relationship automatically: their session, rapport, and history come back.
Handles are normalized (a leading @ and casing are smoothed out), so you don't have to canonicalize before you send them.
Session
One DM conversation thread. A session is the running thread between the character and one lead. It is auto-created and auto-resumed per (character, platform, handle) — you almost never manage it yourself. The reply from POST /chat returns the session_id it used:
json
{
"data": {
"session_id": "sess_9d1e0c4a7b6f2358e9a0b1c2d3e4f506",
"bubbles": [
{ "text": "heyy 👀", "delay_ms": 0, "image_url": null },
{ "text": "what made you message me", "delay_ms": 1400, "image_url": null }
]
},
"request_id": "req_2b44d8fbb0e7c5a1"
}- Omit
session_idand the API resumes (or creates) the lead's natural conversation — the normal case. - Pass
session_idonly to pin a turn to one specific thread (for example, to keep a re-delivered inbound in the same conversation — see Idempotency). - If the conversation has been closed,
bubblesis[](the character has nothing more to say).
Person: cross-surface awareness
The headline feature: one human is one persona, everywhere. A lead who DMs the character and comments on its posts is treated as the same Person. The character stays consistent and remembers across surfaces (DM ↔ comments) and across multiple posts and threads — it won't reintroduce itself, contradict an earlier chat, or forget what the two of you already covered.
You don't construct a Person or pass a person id; the API knits one together from the leads it sees. Two response fields reflect it:
| Field | Where | Meaning |
|---|---|---|
recognized_lead | /comments | true when the commenter is someone the character already knows (e.g. has DMed before). |
aware | /comments, /comments/reply, /comments/engage | A cross-surface awareness summary — what the character knows about this Person from other surfaces and threads — that shaped the generated text. |
json
{
"data": {
"ok": true,
"comment": "ok the lighting in this is unreal ✨ how was the trip??",
"recognized_lead": true,
"aware": { "surfaces": ["dm", "comments"], "known_facts": ["planning a trip to Lisbon"] },
"thread_id": "thr_7c5a1d9e3f04"
},
"request_id": "req_0f3a6b8e1d22"
}This is why you pass the same (platform, handle) everywhere it applies: it lets the character behave like one coherent persona in DMs and in public, not a fresh stranger on each surface.
Bubbles
A reply is a list of short messages, not one blob. Real people send a few quick chat bubbles in a row, with pauses between them — so POST /chat (and a cold trigger) returns an ordered bubbles array. Send them in order, honoring each delay, to mimic human typing cadence.
| Field | Type | Description |
|---|---|---|
text | string | The bubble's message text. |
delay_ms | integer | How long to wait before sending this bubble, in milliseconds. |
image_url | string or null | An image the character chose to send with this bubble, or null for text only. |
json
[
{ "text": "omg wait", "delay_ms": 0, "image_url": null },
{ "text": "you're in Lisbon too??", "delay_ms": 1200, "image_url": null }
]An empty bubbles array means the character is intentionally staying silent (closed conversation, or an ignored inbound) — send nothing.
Triggers / entry points
Where a conversation can START. Beyond replying to an inbound DM, a character can be kicked off by an entry-point event — an outreach, a story reaction, a new follower, or a custom event you define. Custom entry points are configured per platform in the dashboard and fired with POST /triggers, addressed by their event_id; outreach is reserved and always available, since every character has an Outreach node.
How a trigger behaves depends on whether a chat already exists:
- Cold (no live conversation) → the character opens the conversation, reacting to the event. The response carries
opened: trueand abubblesarray to send. - Warm (a chat is already running) → the event becomes the next turn's stimulus, folded into the ongoing thread.
json
{
"data": {
"ok": true,
"deduped": false,
"opened": true,
"session_id": "sess_9d1e0c4a7b6f2358e9a0b1c2d3e4f506",
"bubbles": [ { "text": "thanks for the follow 🙈", "delay_ms": 0, "image_url": null } ]
},
"request_id": "req_a1b2c3d4e5f6"
}An unknown event_id is a safe no-op ({ "ok": true, "ignored": "event not configured" }), so you can wire up a webhook before the entry point exists in the dashboard without breaking anything.
Follow-ups
Proactive re-engagement, delivered as a pull-queue. A character periodically queues "hey stranger" rekindle messages for dormant leads. You don't get these pushed at you — you pull them, send each one on the platform, then ack them. This makes the queue safe to poll on a schedule and idempotent to retry.
The two-step cycle:
GET /followups— pull this connector's pending follow-ups (oldest first).POST /followups/{id}/ack— mark one delivered once you've sent it (terminal, idempotent).
Each follow-up looks like:
| Field | Type | Description |
|---|---|---|
id | string | The follow-up id — pass it to ack. |
handle | string | The lead's @username to send to. |
platform | string | The platform to send on. |
kind | string | The follow-up type (e.g. a rekindle). |
message | string | The text to send. |
created_at | ISO-8601 UTC | When the follow-up was queued. |
json
{
"data": {
"followups": [
{
"id": "fu_3e51b9a8d771c6e5",
"handle": "mark",
"platform": "instagram",
"kind": "rekindle",
"message": "hey stranger, you disappeared on me 🥲",
"created_at": "2026-06-22T08:05:00Z"
}
]
},
"request_id": "req_b2f9a40f0a4d2c8"
}Pull, deliver, then ack. A follow-up stays
pendinguntil you ack it, so an interrupted run simply re-pulls it next time — you never silently drop a rekindle.
Comment threads
The character's public comment plus the back-and-forth under it. A character can engage in public, not just in DMs. You hand the API a post and it generates a top-level comment to post; when someone replies, you hand it the reply and it generates a threaded reply — to the original poster or to other commenters, and it can fire multiple times in one thread.
POST /comments→ a top-level comment for a post. Returns thecommenttext, plusrecognized_leadandaware(see Person) and athread_id.POST /comments/reply→ a reply within a thread. Returns adecision("comment_reply","drive_to_dm_nudge","skip", or"bow_out") and thereplytext — which isnullon the last two.POST /comments/engage→ a reply that joins a conversation between other people, under a post the character never commented on. Nobody addressed us, so your connector sends thethreadit can see, and the character picks the one comment to answer — or abstains, which is the normal outcome and comes back asreply: null.
json
{
"data": {
"ok": true,
"decision": "drive_to_dm_nudge",
"reply": "haha you're trouble — dm me and i'll tell you 👀",
"aware": { "surfaces": ["comments"], "thread_turns": 2 }
},
"request_id": "req_5b2f9a400f3a6b8e"
}Threads are cross-thread aware — the character remembers a Person across different posts and threads, and across the DM ↔ comment boundary. If comments aren't enabled for this character on this platform, the call is a safe no-op: { "ok": true, "ignored": "comments not configured" }.
The request/response envelope
Every endpoint returns the same envelope, so you parse responses one way across the whole API.
On success (HTTP 2xx) the real payload is always under data, alongside a request_id:
json
{
"data": { "...": "the endpoint's result" },
"request_id": "req_2b44d8fbb0e7c5a1"
}On error (HTTP 4xx/5xx) you get a structured error object instead:
json
{
"error": {
"code": "invalid_token",
"message": "The connector token is missing or not recognized.",
"type": "client_error",
"request_id": "req_2b44d8fbb0e7c5a1"
}
}Every response — success or error — also carries an X-Request-Id header with the same value as request_id. Quote it when you contact support or grep your logs. The full status → code mapping is on the Errors page; the SDKs unwrap data for you and raise a typed error per code.
Idempotency
Webhooks get re-delivered — so the event endpoints are idempotent, and chat is not.
POST /eventsandPOST /triggersrequire anexternal_event_id(unique per owner + platform). A re-delivered webhook with the same id applies exactly once; repeats returndeduped: trueand change nothing. Use your platform's native event id as the key.POST /followups/{id}/ackis idempotent on the follow-up id — acking an already-delivered follow-up is a safe no-op.POST /chathas no message id, so a re-sent inbound creates a new turn. Your connector should ensure at-most-once delivery of inbound DMs — or pass a stablesession_idto keep a retried inbound in the same conversation.
json
{
"data": { "ok": true, "deduped": true },
"request_id": "req_9a0b1c2d3e4f5061"
}Multi-account dedup
One character, several accounts on the same platform, but only one ever replies. A character can run multiple accounts on one platform (e.g. two Instagram accounts). With dedup enabled on the character, the first account a lead messages claims that lead; a later inbound from a different account is ignored, so a single lead never gets answered twice.
To participate, tell the API which account received each event:
- Pass
own_username(the bot account that received the DM) on/chatand/triggers. - Pass
?own_username=on/followups— a claimed follow-up is delivered only to the account that owns it.
When a call is deduped away, /chat tells you to send nothing:
json
{
"data": {
"session_id": null,
"bubbles": [],
"ignored": true,
"ignore_reason": "lead_claimed_by_other_account"
},
"request_id": "req_8d771c6e5b2f9a40"
}Dedup is off by default — omit own_username and every account behaves normally. Full setup, the "mother/slave" model, and edge cases are in Multi-account dedup.
Where to go next
- Make your first call: Quickstart
- How auth works: Authentication
- Reply to DMs: Chat reference
- Open conversations: Triggers reference · Re-engage: Follow-ups reference
- Go public: Comments · Comment replies · Join someone else's thread
- Report sales: Events reference
- Run multiple accounts: Multi-account dedup
- What it costs: Billing
- Use a client library: Python SDK · TypeScript SDK
- Full endpoint catalog: API reference
- When things go wrong: Errors · Rate limits