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 platform settings — the token is the character, so there is no account-level key and no characterId argument. The token is shown once; 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_live_…). 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_live_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
ft.commentReply({…})POST /comments/replyCommentReplyResult
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 (PENDINGDELIVERED). 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.


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 "engage" / "drive_to_dm" / "skip"
}

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"
  billing?: Billing;
}

interface CommentReplyResult {
  ok: boolean;
  decision: string;                 // "engage" / "drive_to_dm" / "skip"
  reply: string | null;             // null when the character declines to reply
  reason?: string;
  aware?: Record<string, unknown>;
  billing?: Billing;
}

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
PaymentRequiredError402payment_requiredBilling 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.