Skip to content

TypeScript SDK ​

The official TypeScript / JavaScript client for the FluidTalk Characters API. It wraps the REST API described in the API reference: it attaches your connector token, takes care of the { data, request_id } response envelope (handing you the inner data), and maps HTTP errors to typed exceptions.

The SDK talks to the same endpoints documented in the reference, so anything you can do here you can also do with raw HTTP. Your token authenticates as one character; you name the platform in each call, and the same token works across every platform that character runs on.


Install ​

bash
npm install fluidtalk@^2

Requirements:

  • Node 18+ (the SDK uses the global fetch; it ships with Node 18+). It also runs in modern browsers and edge runtimes that expose fetch — but keep the token server-side (see Authentication).
  • The package is ESM. Import it with import, or use a bundler / "type": "module". In CommonJS, load it with dynamic import().

You need a per-character connector token. Tokens are created in the FluidTalk dashboard under the character's API tab — the token is the character, so there is no account-level key and no characterId argument. The account owner can show or copy the token there any time; treat it like a password. See Authentication.


Construct the client ​

ts
import { FluidTalk } from "fluidtalk";

const ft = new FluidTalk({ token: process.env.FLUIDTALK_TOKEN! });

The constructor takes a single options object (FluidTalkOptions):

OptionTypeDefaultNotes
tokenstring— (required)Your per-character connector token (ftc_…). Sent as the X-Connector-Token header. Throws if missing.
baseUrlstringhttps://api-talk.fluidvip.comAPI origin. The SDK appends /api/v1/characters for you. Trailing slashes are trimmed.
timeoutnumber30000Per-request timeout in milliseconds.
fetchtypeof fetchthe runtime's global fetchOverride the fetch implementation (tests, or runtimes without a global fetch).

If your runtime has no global fetch and you do not pass options.fetch, the constructor throws.

ts
const ft = new FluidTalk({
  token: "ftc_8f3c...",
  baseUrl: "https://api-talk.fluidvip.com",
  timeout: 30000,
});

Methods ​

The client exposes the character's connector surface as a flat set of methods, plus a followups namespace. Every method returns a Promise and resolves to the inner data of the envelope (the SDK unwraps { data, request_id } for you). All timestamps are ISO-8601 UTC strings.

MethodHTTPReturns
ft.chat({…})POST /chatChatResult
ft.event({…})POST /eventsEventResult
ft.trigger({…})POST /triggersTriggerResult
ft.followups.list({…})GET /followupsFollowup[]
ft.followups.ack(id)POST /followups/{id}/ackAckResult
ft.comment({…})POST /commentsCommentResult (maxChars 2.8.0)
ft.commentReply({…})POST /comments/replyCommentReplyResult (maxChars 2.8.0)
ft.commentEngage({…})POST /comments/engageCommentEngageResult (2.2.0; maxChars 2.8.0)
ft.commentMedia({…})POST /comments/mediaCommentMediaResult (2.2.0)
ft.post({…})POST /postsPostResult (2.3.0; reuseAnalysis 2.4.0; alwaysDraft 2.6.0; thresholds 2.7.0; count 2.9.0)
ft.postMedia({…})POST /posts/mediaPostMediaResult (2.5.0)
ft.inboundMedia({…})POST /inbound-mediaInboundMediaResult

Method arguments are camelCase (imageUrl, externalEventId, ownUsername, postRef, …); the SDK maps them to the snake_case wire fields for you.


ft.chat ​

The lead sent the character a DM — get the character's reply. A reply is a list of bubbles: short messages with human-like delays. Send them in order, waiting delayMs between each.

ts
const res = await ft.chat({
  platform: "instagram",
  handle: "mark",
  message: "hey, saw your story 🔥",
  // imageUrl, sessionId, ownUsername are optional
});

for (const b of res.bubbles) {
  await new Promise((r) => setTimeout(r, b.delay_ms));
  // send b.text (and b.image_url, when set) on the platform yourself
}
  • imageUrl — an image the lead sent; the character describes it via vision.
  • sessionId — resume a specific conversation. Omit to auto-resume/create the session for this (platform, handle).
  • ownUsername — the bot account that received this DM, for multi-account dedup. When dedup ignores the inbound, the result is { session_id: null, bubbles: [], ignored: true, ignore_reason: "lead_claimed_by_other_account" } — send nothing.

If the conversation is already closed, bubbles is []. See POST /chat.


ft.event ​

Report that money moved (purchase / refund / chargeback). The API authoritatively flips the lead's lifecycle; you only report the fact. Idempotent on externalEventId.

ts
const ev = await ft.event({
  platform: "instagram",
  handle: "mark",
  eventType: "purchase",         // "purchase" | "refund" | "chargeback" (default "purchase")
  externalEventId: "stripe_evt_9f2",  // your idempotency key
  amount: 25,
  currency: "USD",
});

console.log(ev.stage, ev.total_spend);  // e.g. "CUSTOMER" 25

A duplicate externalEventId returns { ok: true, deduped: true } and does not re-apply. See POST /events.


ft.trigger ​

Fire a conversation trigger / entry event (story reaction, new follower, custom). Cold (no live chat) → the character opens the conversation reacting to the event. Warm (a chat exists) → the event becomes the next turn's stimulus. Idempotent on externalEventId.

ts
const t = await ft.trigger({
  platform: "instagram",
  handle: "mark",
  eventId: "new_follower",        // a configured entry-point key
  externalEventId: "ig_follow_5512",
  context: "started following",   // optional short detail for the opener
  // ownUsername is optional (dedup)
});

if (t.opened) {
  for (const b of t.bubbles) {
    await new Promise((r) => setTimeout(r, b.delay_ms));
    // send b.text on the platform
  }
}

An unconfigured eventId is a safe no-op: { ok: true, ignored: "event not configured" }. A duplicate externalEventId returns { ok: true, deduped: true }. See POST /triggers.


ft.followups.list ​

Pull this connector's pending proactive follow-ups (oldest first). The character periodically queues "hey stranger" rekindles for dormant leads; you pull them, send each on the platform, then ack. The SDK unwraps the response to a Followup[].

ts
const followups = await ft.followups.list({
  platform: "instagram",  // required for character-token callers
  // ownUsername, limit (default 100, max 500) are optional
});

for (const f of followups) {
  // send f.message to f.handle on f.platform...
  await ft.followups.ack(f.id);  // ...then ack it
}

With multi-account dedup, pass ownUsername so a claimed row is delivered only to that account. See GET /followups.


ft.followups.ack ​

Acknowledge that a pulled follow-up was delivered (PENDING → DELIVERED). Terminal and idempotent.

ts
const acked = await ft.followups.ack("fu_8a31c0");
console.log(acked.delivered_at);  // ISO-8601

Re-acking returns { ok: true, deduped: true }. See POST /followups/{id}/ack.


ft.comment ​

Generate a top-level public comment for a post. You post the returned text yourself — the API never posts anything.

ts
const c = await ft.comment({
  platform: "instagram",
  postRef: "https://instagram.com/p/Cxyz123",   // the platform's unique id/URL
  caption: "golden hour in Lisbon ☀️",
  imageUrls: ["https://cdn.example.com/post.jpg"],
  authorHandle: "mark",
});

if (!c.ignored) {
  // post c.comment under the post yourself
  // c.recognized_lead / c.aware reflect cross-surface awareness
}

If comments aren't enabled for this character/platform: { ok: true, ignored: "comments not configured" }. See POST /comments.

maxChars (2.8.0, also on commentReply and commentEngage) sets the longest comment for this one call, 20–2200 characters, overriding the workflow's Max comment length; it is sent as max_chars and left out when you don't pass it. The result's max_chars is the limit applied — never above the platform's ceiling — and truncated says whether the text was shortened to fit and reads as unfinished. A value outside 20–2200 throws ApiError (400). See Comment length.


ft.commentReply ​

Generate a threaded reply to a reply on the character's comment. Can fire multiple times in a thread, to the original poster or to other commenters.

ts
const r = await ft.commentReply({
  platform: "instagram",
  postRef: "https://instagram.com/p/Cxyz123",
  replierHandle: "mark",
  replyText: "haha you're funny, do you have a page?",
  // parentCommentRef and a post object (for a new thread) are optional
});

if (r.reply) {
  // post r.reply in the thread; r.decision is "comment_reply" / "drive_to_dm_nudge" / "skip" / "bow_out"
}

ft.commentEngage ​

Added in 2.2.0.

Join a conversation between other people, under a post the character may never have touched. thread is required — we never saw those comments, so what you send is the character's only context. Thread rows are camelCase like every other argument (commentRef, authorHandle, parentRef); the optional post object is passed through as-is, so its fields stay snake_case.

ts
const res = await ft.commentEngage({
  platform: "reddit",
  postRef: "t3_1abc234",
  post: { caption: "gap between tub and tile keeps cracking", author_handle: "newhomeowner" },
  thread: [
    { commentRef: "t1_a1", authorHandle: "newhomeowner", depth: 0,
      text: "its about half an inch wide" },
    { commentRef: "t1_a3", authorHandle: "drywall_dave", parentRef: "t1_a1", depth: 1,
      text: "just run a thick bead of silicone over it, it will hold fine at that width" },
  ],
});

if (res.reply) {                       // null whenever the character passed
  await postReply(res.target_comment_ref!, res.reply);   // reply to THAT comment
}

Omit targetCommentRef and the character picks the comment worth answering — or abstains, which is the normal outcome, not an error. Branch on res.reply, never on the call having succeeded; res.reason names the stop rule. See POST /comments/engage.


ft.commentMedia ​

Added in 2.2.0.

Upload a post's image bytes and get a permanent URL for comment's imageUrls. We don't fetch a post's image — the model provider does, from the URL you send — so a host that refuses that fetcher produces a blind comment (vision.seen: false). Rehosting through here produces a URL the provider can read.

ts
const up = await ft.commentMedia({
  platform: "reddit",
  postRef: "t3_1abc234",
  data: bytes,               // Uint8Array | ArrayBuffer; a string is taken as already-base64
  filename: "post.png",
});

await ft.comment({ platform: "reddit", postRef: "t3_1abc234", imageUrls: [up.url!] });

Not billed; max 10 MB decoded; the content type is sniffed from the bytes when it disagrees with the label. Not inboundMedia — that one is for a lead-sent DM photo and files into the character's "<name> — Inbound" folder, while a post's image gets its own "<name> — Comments" folder. See the Comments reference.


ft.post with count ​

count added in 2.9.0.

Pass count (1–5) to get several distinct posts for one community from one call. res.post is still the best post; res.posts (PostEntry[]) lists every post that cleared every gate (each with its own post_id), res.requested and res.returned count them, and res.shortfall_reason says why when there are fewer. Every floor and limit applies to each post, so strict floors (especially min_novelty) can return fewer than you asked for, or none (the usual no_suitable_post_found).

ts
const res = await ft.post({
  platform: "reddit",
  community: { ref: "r/running", rules: ["No promotion."] },
  topComments,
  count: 3,
});

if (res.decision === "post") {
  for (const p of res.posts ?? []) await submit(p.title, p.body, p.flair);   // best first
  if ((res.returned ?? 0) < (res.requested ?? 0)) log(res.shortfall_reason);  // e.g. "novelty"
}

One charge covers the call, less than that many separate calls, and each delivered post gets its own history row. A count outside 1–5 throws ApiError (400) before anything is billed. See Several posts in one call.

ft.postMedia ​

Added in 2.5.0.

Upload the image the character is about to post and get a permanent URL for post's assetUrl. The model provider reads a post's image by URL before a word is written about it, so bytes you only hold locally (a screenshot, a camera roll), or an image on a host that refuses that fetcher, have to be rehosted first — an image it cannot see ends the post generation_failed.

ts
const up = await ft.postMedia({
  platform: "reddit",
  communityRef: "r/running",  // the same value as community.ref on post
  data: bytes,                // Uint8Array | ArrayBuffer; a string is taken as already-base64
  filename: "route.png",
});

if (up.url) {                  // null on a no-op: posting off, or the character paused
  await ft.post({ platform: "reddit", community: { ref: "r/running" }, assetUrl: up.url });
}

Not billed; max 10 MB decoded; the bytes must be a still JPEG, PNG, WebP or GIF — video or HEIC throws ApiError (400), so convert it first. The content type is sniffed from the bytes, and the bytes win over the label. Not commentMedia — that one is for the image on someone else's post and files into "<name> — Comments", while an image the character posts itself gets its own "<name> — Posts" folder. See the Posts reference.


ft.inboundMedia ​

Added in 2.1.0. Upload the bytes of a photo the lead sent, and get a permanent URL to pass to chat as imageUrl. For platforms that hand you a file rather than a link we can fetch — a Telegram file_id you download with your own bot token, or an Instagram CDN URL that is signed and expires.

ts
const up = await ft.inboundMedia({
  platform: "instagram",
  data: rawBytes,            // Uint8Array | ArrayBuffer; a string is taken as already-base64
  filename: "photo.jpg",
});

await ft.chat({ platform: "instagram", handle: "mark", message: "what do you think? 😏", imageUrl: up.url });

Max 10 MB decoded — larger throws ApiError (413). Works in Node and the browser: Buffer is used where it exists, otherwise a chunked btoa. Skip this entirely when you already have a publicly-fetchable URL: pass it straight to chat as imageUrl. See the Media reference.

reply is null when the character chooses not to reply (decision: "skip"). See POST /comments/reply.


Result types ​

The SDK resolves to plain objects mirroring the inner data of each response. Key shapes (all *_at are ISO-8601 UTC strings):

ts
interface Bubble {
  text: string;
  delay_ms: number;
  image_url: string | null;
}

interface Billing {                 // present on billing-bearing responses (1 token = $1)
  tokens: number;                   // what this message cost you = real cost × plan rate (e.g. 0.00127932)
  tokens_used: number;              // raw model cost of this turn, in tokens (real $ before plan rate)
  balance_after: number;
  collected: boolean;
}

interface ChatResult {
  session_id: string | null;        // null when an inbound is deduped away
  bubbles: Bubble[];                // [] when the conversation is closed
  ignored?: boolean;                // true when multi-account dedup ignored it
  ignore_reason?: string;           // e.g. "lead_claimed_by_other_account"
  billing?: Billing;
}

interface EventResult {
  ok: boolean;
  deduped: boolean;                 // true on a repeated external_event_id
  event_type: string;
  stage: string;                    // the lead's new lifecycle stage
  sale_count: number;
  total_spend: number;
}

interface TriggerResult {
  ok: boolean;
  deduped: boolean;
  opened: boolean;                  // true when the character opened a cold chat
  session_id: string | null;
  bubbles: Bubble[];
  ignored?: string;                 // "event not configured" when eventId is unknown
  billing?: Billing;
}

interface Followup {
  id: string;
  handle: string;
  platform: string;
  kind: string;
  message: string;
  created_at: string;
}

interface AckResult {
  ok: boolean;
  deduped: boolean;
  delivered_at: string;
}

interface CommentResult {
  ok: boolean;
  comment: string;                  // the comment text to post
  aware: Record<string, unknown>;   // cross-surface awareness summary
  recognized_lead: boolean;         // this commenter is a known Person
  thread_id?: string;
  ignored?: string;                 // "comments not configured"
  max_chars?: number;               // the length limit applied (2.8.0)
  truncated?: boolean;              // shortened to fit and reads as unfinished (2.8.0)
  billing?: Billing;
}

interface CommentReplyResult {
  ok: boolean;
  decision: string;                 // "comment_reply" | "drive_to_dm_nudge" | "skip" | "bow_out"
  reply: string | null;             // null when the character declines to reply
  reason?: string;
  aware?: Record<string, unknown>;
  max_chars?: number;               // only when a reply was written (2.8.0)
  truncated?: boolean;              // only when a reply was written (2.8.0)
  billing?: Billing;
}

interface CommentEngageResult {
  ok: boolean;
  decision?: string;                // "comment_reply" | "skip" | "bow_out" (never drive_to_dm_nudge)
  reply?: string | null;            // null on every skip — the NORMAL outcome; branch on this
  target_comment_ref?: string | null;  // post the reply under THIS comment
  target_author_handle?: string;
  reason?: string;                  // why that comment, or which stop rule fired
  aware?: Record<string, unknown>;
  recognized_lead?: boolean;
  thread_id?: string | null;
  explain?: string;                 // plain-English detail when nothing was generated
  max_chars?: number;               // only when a reply was generated (2.8.0)
  truncated?: boolean;              // only when a reply was generated (2.8.0)
  billing?: Billing;                // only when a reply was generated
}

interface CommentMediaResult {
  ok: boolean;
  url: string | null;               // null on a no-op (comments off, or the character paused)
  file_id?: string;
  post_ref?: string;
}

interface PostMediaResult {
  ok: boolean;
  url: string | null;               // null on a no-op (posting off, or the character paused)
  file_id?: string;
  community_ref?: string;
}

Cross-surface awareness

A lead who DMs and comments is one Person. recognized_lead / aware reflect that the character remembers the same person across DMs, comments, posts, and threads — the headline feature: one coherent persona everywhere.


Error handling ​

Every non-2xx response is thrown as a typed error. Import the classes and branch on instanceof:

ts
import {
  FluidTalk,
  AuthError,             // 401 — missing/invalid/revoked connector token
  PaymentRequiredError,  // 402 — wallet can't cover the turn
  PermissionError,       // 403 — forbidden
  NotFoundError,         // 404 — unknown resource (e.g. follow-up id)
  ConflictError,         // 409 — conflicting state
  ValidationError,       // 422 — request body failed validation
  RateLimitError,        // 429 — too many requests
  ApiError,              // any other status (400, 5xx, ...)
} from "fluidtalk";

try {
  const res = await ft.chat({ platform: "instagram", handle: "mark", message: "hey" });
  for (const b of res.bubbles) { /* send b.text */ }
} catch (err) {
  if (err instanceof PaymentRequiredError) {
    // top up the character owner's wallet, then retry — see Billing
  } else if (err instanceof RateLimitError) {
    // honor the Retry-After header before retrying
  } else if (err instanceof ValidationError) {
    console.error(err.code, err.message);  // bad body
  } else if (err instanceof AuthError) {
    // rotate/replace the connector token
  } else if (err instanceof ApiError) {
    console.error(err.status, err.code, err.requestId);
    throw err;
  } else {
    throw err;
  }
}
ErrorHTTP statuserror.codeWhen
AuthError401invalid_tokenMissing, invalid, or revoked connector token
PaymentRequiredError402insufficient_tokensBilling on and the wallet can't cover the turn
PermissionError403forbiddenThe token may not perform this action
NotFoundError404not_foundThe resource does not exist
ConflictError409conflictConflicting state
ValidationError422validation_errorThe request body failed validation
RateLimitError429rate_limitedToo many requests; honor Retry-After
ApiErrorany other (400, 500, …)invalid_request / internal_error / …Catch-all base

AuthError, PaymentRequiredError, PermissionError, NotFoundError, ConflictError, ValidationError, and RateLimitError all extend ApiError, so a single catch (err) { if (err instanceof ApiError) … } covers every API failure. Each error carries the HTTP status, the response body's code, message, and type, and the requestId (the same value as the X-Request-Id header) for support/log correlation.

402 is charged before any model call

When billing is enabled and the wallet can't cover a turn, the API returns 402 before running the model — so you are never charged for a refused turn. Handle PaymentRequiredError by topping up the wallet in the dashboard and surfacing it to the operator. See Billing.

A 429 surfaces as RateLimitError (extends ApiError with status === 429); honor the Retry-After header before retrying. See Errors and Rate limits.


Full example ​

A minimal connector loop: take a DM in, send the character's bubbles, report a purchase, then drain the follow-up queue.

ts
import {
  FluidTalk,
  PaymentRequiredError,
  ApiError,
} from "fluidtalk";

const ft = new FluidTalk({ token: process.env.FLUIDTALK_TOKEN! });

// 1. A lead (Ava's follower @mark) sent a DM on Instagram — get her reply.
try {
  const res = await ft.chat({
    platform: "instagram",
    handle: "mark",
    message: "hey, saw your story 🔥",
  });

  for (const b of res.bubbles) {
    await new Promise((r) => setTimeout(r, b.delay_ms));
    await sendOnInstagram("mark", b.text, b.image_url);  // your platform I/O
  }
} catch (err) {
  if (err instanceof PaymentRequiredError) {
    // wallet empty — top up and retry
  } else if (err instanceof ApiError) {
    console.error(err.status, err.code, err.requestId);
  }
}

// 2. Stripe told us @mark just paid — report it (idempotent on externalEventId).
const ev = await ft.event({
  platform: "instagram",
  handle: "mark",
  eventType: "purchase",
  externalEventId: "stripe_evt_9f2",
  amount: 25,
});
console.log("lead is now", ev.stage, "spend", ev.total_spend);

// 3. Drain proactive follow-ups for dormant leads: send + ack.
const followups = await ft.followups.list({ platform: "instagram" });
for (const f of followups) {
  await sendOnInstagram(f.handle, f.message);
  await ft.followups.ack(f.id);
}

Notes & limits ​

  • The API generates, you deliver. FluidTalk produces the character's messages and tracks the relationship; it never posts anything. How you read and send on Instagram, X, Reddit, Fanvue, TikTok, or anywhere else is your connector's concern.
  • One token = one character, many platforms. Name the platform in each call; the bound per-platform workflow is selected for you. A character can also run several accounts on one platform — pass ownUsername for multi-account dedup.
  • Idempotency. event and trigger are idempotent on externalEventId (a re-delivered webhook applies exactly once; repeats return deduped: true). chat has no message id, so a re-sent inbound creates a new turn — ensure at-most-once delivery of inbound DMs, or pass a stable sessionId.
  • Billing. Billing-bearing results carry a billing object (tokens, tokens_used, balance_after, collected). See Billing.
  • Promise-based. Every method returns a Promise; use await or .then(). Errors reject with the typed classes above.

See also: Python SDK · Quickstart · Concepts · Authentication · Billing · API reference.

FluidTalk Characters API — part of the Fluidvip ecosystem.