Skip to content

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_8f3c2a7b8e1d4056a1

Send 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 — mark on Instagram and mark on 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_id and the API resumes (or creates) the lead's natural conversation — the normal case.
  • Pass session_id only 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, bubbles is [] (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:

FieldWhereMeaning
recognized_lead/commentstrue when the commenter is someone the character already knows (e.g. has DMed before).
aware/comments, /comments/reply, /comments/engageA 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.

FieldTypeDescription
textstringThe bubble's message text.
delay_msintegerHow long to wait before sending this bubble, in milliseconds.
image_urlstring or nullAn 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: true and a bubbles array 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:

  1. GET /followups — pull this connector's pending follow-ups (oldest first).
  2. POST /followups/{id}/ack — mark one delivered once you've sent it (terminal, idempotent).

Each follow-up looks like:

FieldTypeDescription
idstringThe follow-up id — pass it to ack.
handlestringThe lead's @username to send to.
platformstringThe platform to send on.
kindstringThe follow-up type (e.g. a rekindle).
messagestringThe text to send.
created_atISO-8601 UTCWhen 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 pending until 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 the comment text, plus recognized_lead and aware (see Person) and a thread_id.
  • POST /comments/reply → a reply within a thread. Returns a decision ("comment_reply", "drive_to_dm_nudge", "skip", or "bow_out") and the reply text — which is null on 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 the thread it can see, and the character picks the one comment to answer — or abstains, which is the normal outcome and comes back as reply: 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 /events and POST /triggers require an external_event_id (unique per owner + platform). A re-delivered webhook with the same id applies exactly once; repeats return deduped: true and change nothing. Use your platform's native event id as the key.
  • POST /followups/{id}/ack is idempotent on the follow-up id — acking an already-delivered follow-up is a safe no-op.
  • POST /chat has 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 stable session_id to 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 /chat and /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 ​

FluidTalk Characters API — part of the Fluidvip ecosystem.