Appearance
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@^2Requirements:
- Node 18+ (the SDK uses the global
fetch; it ships with Node 18+). It also runs in modern browsers and edge runtimes that exposefetch— 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 dynamicimport().
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):
| Option | Type | Default | Notes |
|---|---|---|---|
token | string | — (required) | Your per-character connector token (ftc_live_…). Sent as the X-Connector-Token header. Throws if missing. |
baseUrl | string | https://api-talk.fluidvip.com | API origin. The SDK appends /api/v1/characters for you. Trailing slashes are trimmed. |
timeout | number | 30000 | Per-request timeout in milliseconds. |
fetch | typeof fetch | the runtime's global fetch | Override 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.
| Method | HTTP | Returns |
|---|---|---|
ft.chat({…}) | POST /chat | ChatResult |
ft.event({…}) | POST /events | EventResult |
ft.trigger({…}) | POST /triggers | TriggerResult |
ft.followups.list({…}) | GET /followups | Followup[] |
ft.followups.ack(id) | POST /followups/{id}/ack | AckResult |
ft.comment({…}) | POST /comments | CommentResult |
ft.commentReply({…}) | POST /comments/reply | CommentReplyResult |
ft.inboundMedia({…}) | POST /inbound-media | InboundMediaResult |
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" 25A 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-8601Re-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;
}
}| Error | HTTP status | error.code | When |
|---|---|---|---|
AuthError | 401 | invalid_token | Missing, invalid, or revoked connector token |
PaymentRequiredError | 402 | payment_required | Billing on and the wallet can't cover the turn |
PermissionError | 403 | forbidden | The token may not perform this action |
NotFoundError | 404 | not_found | The resource does not exist |
ConflictError | 409 | conflict | Conflicting state |
ValidationError | 422 | validation_error | The request body failed validation |
RateLimitError | 429 | rate_limited | Too many requests; honor Retry-After |
ApiError | any 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
platformin each call; the bound per-platform workflow is selected for you. A character can also run several accounts on one platform — passownUsernamefor multi-account dedup. - Idempotency.
eventandtriggerare idempotent onexternalEventId(a re-delivered webhook applies exactly once; repeats returndeduped: true).chathas no message id, so a re-sent inbound creates a new turn — ensure at-most-once delivery of inbound DMs, or pass a stablesessionId. - Billing. Billing-bearing results carry a
billingobject (tokens,tokens_used,balance_after,collected). See Billing. - Promise-based. Every method returns a
Promise; useawaitor.then(). Errors reject with the typed classes above.
See also: Python SDK · Quickstart · Concepts · Authentication · Billing · API reference.