Skip to content

API Reference — Comments ​

Generate the character's public-comment activity: a top-level comment on a post, threaded replies to people who reply underneath it, and replies that join a conversation between other people entirely. You report the post (and any incoming reply, or the thread you can see); the API returns the words the character should say. The connector posts the returned text — FluidTalk generates the comment and tracks the relationship, it never touches the platform itself.

All routes are under the production base URL:

https://api-talk.fluidvip.com/api/v1/characters

Every response is wrapped in the standard envelope — the real payload is under data, alongside a request_id (also returned as the X-Request-Id header). The shapes below describe the inner data. See Errors for the envelope and error model.

MethodPathPurpose
POST/commentsGenerate a top-level comment for a post
POST/comments/replyGenerate a threaded reply to a reply
POST/comments/engageJoin a thread between other people — reply to someone else's comment
POST/comments/mediaRehost a post's image and get a URL vision can read

Authentication. Send your per-character connector token in the X-Connector-Token header. The token is the character — there is no character_id; you name the platform in each request body and the bound workflow is selected for you. See Authentication.

One persona everywhere

Comments are cross-surface aware. The same lead who comments and DMs is one Person, so the character stays consistent and remembers across surfaces (DM ↔ comments) and across multiple posts and threads. recognized_lead and the aware summary reflect what the character already knows about this Person from elsewhere. See Concepts.


The post object ​

These endpoints take an optional post object that gives the character context about what it is commenting on. Image URLs are described via vision, so the character can reference what is actually shown.

FieldTypeDescription
captionstringThe post's caption text.
image_urlsstring[]URLs of the post's images. Each is described via vision so the comment can react to the picture, not just the words.
author_handlestringThe @username of whoever authored the post.

On POST /comments the post object carries the post being commented on. On POST /comments/reply and POST /comments/engage it is optional — pass it only when the thread is new and the character hasn't seen this post yet, which on /comments/engage is usually the case.

A public image URL is not always a readable one

We don't fetch the image — the model provider does, from the URL you send. So the bar isn't "is this URL public", it's "does that host serve the provider's fetcher", and genuinely public hosts fail it (upload.wikimedia.org renders in any browser and comes back vision_failed).

When the fetch fails, vision.seen is false and the comment is written from the caption alone — confidently, and sometimes about details that were never in the picture. Always log vision.seen; it is the only thing separating "the character saw the image" from "the character invented one".

If a platform's image host refuses the provider, send the bytes to POST /comments/media and put the URL it returns in image_urls instead.


Comment length ​

POST /comments, /comments/reply and /comments/engage all hold the generated text to one limit, the first of:

  1. max_chars in the request body — an integer from 20 to 2200, for this call only;
  2. the workflow's max_comment_chars comments setting, when it is set (null or 0 means unset);
  3. the default, 150 characters on every platform.

Whichever applies is lowered to the platform's ceiling when it is above it; the ceiling also knows ig, insta and x_twitter. Until 2026-10-12 the default was set per platform (Instagram 220, X 280, Reddit 500); it is now 150 everywhere.

platformDefaultCeiling
instagram1502200
tiktok150150
x, twitter, x_twitter150280
reddit1502200
threads150500
any other1502200

The writer is told the limit before it writes, and the text is cut back afterwards if it still runs over: at the last sentence end inside the limit when that keeps at least 40% of it, otherwise at the last comma, semicolon, colon or dash when that keeps at least 40% of it, otherwise at the last space, dropping a trailing comma, colon or dash. Length is counted in Unicode code points. A larger limit lets the writer produce more, so it costs more per comment.

Every response with generated text carries max_chars (the limit applied) and truncated (true only when the text was cut back or the writer ran out of room, and what we return is visibly unfinished: it does not end on terminal punctuation, an emoji or a closing bracket or quote. A complete sentence a longer draft was shortened to is false). They are absent on a skip, a bow_out and the no-op branches. A max_chars outside 20–2200 is a 400 before anything is billed. In the TypeScript SDK it is maxChars and in the Python SDK max_chars, both from 2.8.0.


POST /comments ​

Generate a top-level public comment for a post. You post the returned text on the platform.

Request body ​

NameTypeRequiredDescription
platformstringYesThe platform the post is on, e.g. "instagram". Selects the bound workflow.
post_refstringYesThe platform's unique id or URL for the post. Used to track the comment thread across calls.
postobjectNoThe post object — caption, image URLs, and author handle.
max_charsintegerNoLength limit for this comment, 20–2200, overriding the workflow's max_comment_chars; never above the platform's ceiling. See Comment length.

Response ​

FieldTypeDescription
okbooleanAlways true on success.
commentstringThe comment text to post on the platform. Absent on the not-configured no-op.
awareobjectA cross-surface awareness summary — what the character already knows about this Person from DMs and other threads. Informational; treat its inner fields as illustrative, not a stable contract.
recognized_leadbooleantrue when the post author / commenter is a Person the character already knows from another surface.
thread_idstringPresent once a comment thread exists for this post. Tracked for you — you don't pass it back.
max_charsintegerThe length limit comment was held to. Present when a comment was written. See Comment length.
truncatedbooleantrue when we shortened comment to fit and it reads as unfinished (no terminal punctuation, emoji or closing bracket/quote). Present when a comment was written.
ignoredstringPresent only on the no-op ("comments not configured"); no comment is returned.
json
{
  "data": {
    "ok": true,
    "comment": "okay that light is unreal 😍 how was the pace today?",
    "aware": { "recognized": false, "known_from": [] },
    "recognized_lead": false,
    "thread_id": "thr_3f9c20a1",
    "max_chars": 150,
    "truncated": false
  },
  "request_id": "req_8f3c2a1b"
}

Switched off is a safe no-op. Comments are ON by default, so an integration that has never touched the setting gets real comments (and is billed for them). If someone has opted this workflow out, every call returns 200 with data of { "ok": true, "ignored": "comments not configured", "ignore_reason": "comments_disabled", "comment": null, "explain": "…" } — nothing generated, nothing billed. Branch on ignore_reason. GET /self-test reports comments_enabled per binding, and mode: "mock" with post_ref: "test_disabled" reproduces the branch on demand.

Paused characters generate nothing. If the character is paused (e.g. the account is over its plan's character limit after a downgrade), the call returns 200 with data of { "ok": true, "paused": true, "pause_reason": "plan_limit" }. No comment is generated and nothing is billed; the character resumes automatically once the plan is upgraded. See Paused characters.

Metered. Generating a comment costs model spend and is billed to the character owner's wallet. If the wallet can't cover it you get 402 insufficient_tokens before any model call (you're never charged for a refused generation). See Billing.

Examples ​

bash
curl -X POST https://api-talk.fluidvip.com/api/v1/characters/comments \
  -H "X-Connector-Token: ftc_..." \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "instagram",
    "post_ref": "https://instagram.com/p/Cabc123",
    "post": {
      "caption": "sunset run done 🌅",
      "image_urls": ["https://cdn.example.com/p/abc.jpg"],
      "author_handle": "ava"
    }
  }'
python
from fluidtalk import FluidTalk

ft = FluidTalk(token="ftc_...")

res = ft.comment(
    platform="instagram",
    post_ref="https://instagram.com/p/Cabc123",
    caption="sunset run done 🌅",
    image_urls=["https://cdn.example.com/p/abc.jpg"],
    author_handle="ava",
)

# res.comment is the text to post (None / absent when comments aren't configured)
if res.comment:
    publish_comment_on_instagram(res.comment)  # you post it on the platform
typescript
import { FluidTalk } from "fluidtalk";

const ft = new FluidTalk({ token: "ftc_..." });

const res = await ft.comment({
  platform: "instagram",
  postRef: "https://instagram.com/p/Cabc123",
  caption: "sunset run done 🌅",
  imageUrls: ["https://cdn.example.com/p/abc.jpg"],
  authorHandle: "ava",
});

if (res.comment) {
  await publishCommentOnInstagram(res.comment); // you post it on the platform
}

Status codes ​

StatuscodeWhen
200—Comment generated, or a safe no-op (ignored: "comments not configured").
400invalid_requestMalformed body — e.g. missing platform or post_ref, or a max_chars outside 20–2200. Nothing is billed.
401invalid_tokenMissing, invalid, or revoked connector token.
402insufficient_tokensThe owner's wallet can't cover this generation — no model call was made. Top up. See Billing.
422validation_errorA field failed validation (e.g. post.image_urls isn't a list).
429rate_limitedRate limited; honor the Retry-After header.
500internal_errorUnexpected server error.

POST /comments/reply ​

Generate a reply to a reply on the character's comment. Threaded and cross-thread aware — it can fire multiple times within a thread, to the original poster or to other commenters. The character decides whether to reply, steer the person toward DMs, or stay quiet.

Request body ​

NameTypeRequiredDescription
platformstringYesThe platform the thread is on, e.g. "instagram".
post_refstringYesThe platform's unique id or URL for the post the thread lives under.
replier_handlestringYesThe @username of whoever replied.
reply_textstringNoWhat the replier said.
parent_comment_refstringNoThe platform's id of the comment being replied to, when you have it.
postobjectNoThe post object — pass it only when the thread is new and the character hasn't seen this post yet.
max_charsintegerNoLength limit for this reply, 20–2200, as on POST /comments.

Response ​

FieldTypeDescription
okbooleanAlways true on success.
decisionstringWhat the character chose to do: "comment_reply", "drive_to_dm_nudge", "skip", or "bow_out".
replystring | nullThe reply text to post, or null when the character chose not to reply (a "skip" decision).
reasonstringOn a "skip" or "bow_out", the rule that fired — e.g. "mode=comment_once", "max_replies_per_thread", "reply_to_strangers=off", "hostile/bot-accusation". See the comment settings.
awareobjectOptional cross-surface awareness summary — what the character already knows about this Person. Informational; treat inner fields as illustrative.
max_charsintegerThe length limit reply was held to. Present on "comment_reply" and "drive_to_dm_nudge", absent on "skip" and "bow_out". See Comment length.
truncatedbooleantrue when we shortened reply to fit and it reads as unfinished (no terminal punctuation, emoji or closing bracket/quote). Present exactly when max_chars is.
json
{
  "data": {
    "ok": true,
    "decision": "drive_to_dm_nudge",
    "reply": "ahh you're sweet 😅 dm me, i'll tell you my secret 👀",
    "reason": "warm replier, good moment to move to DMs",
    "aware": { "recognized": true, "known_from": ["dm"] },
    "max_chars": 150,
    "truncated": false
  },
  "request_id": "req_4b19bcc3"
}

When the character decides to stay quiet, reply is null — post nothing:

json
{ "data": { "ok": true, "decision": "skip", "reply": null }, "request_id": "req_9aa97fb0" }

Metered. Like /comments, a generated reply is billed to the owner's wallet, and a wallet that can't cover it returns 402 before any model call. A "skip" or "bow_out" costs essentially nothing: every stop rule is a deterministic check that runs before the model, so no reply is generated. See Billing.

Examples ​

bash
curl -X POST https://api-talk.fluidvip.com/api/v1/characters/comments/reply \
  -H "X-Connector-Token: ftc_..." \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "instagram",
    "post_ref": "https://instagram.com/p/Cabc123",
    "replier_handle": "mark",
    "reply_text": "haha you always make these look easy 😂",
    "parent_comment_ref": "17900000000000001"
  }'
python
res = ft.comment_reply(
    platform="instagram",
    post_ref="https://instagram.com/p/Cabc123",
    replier_handle="mark",
    reply_text="haha you always make these look easy 😂",
    parent_comment_ref="17900000000000001",
)

if res.decision != "skip" and res.reply:
    publish_reply_on_instagram(res.reply)  # you post it on the platform
typescript
const res = await ft.commentReply({
  platform: "instagram",
  postRef: "https://instagram.com/p/Cabc123",
  replierHandle: "mark",
  replyText: "haha you always make these look easy 😂",
  parentCommentRef: "17900000000000001",
});

if (res.decision !== "skip" && res.reply) {
  await publishReplyOnInstagram(res.reply); // you post it on the platform
}

Status codes ​

StatuscodeWhen
200—Reply generated. A "skip" decision (reply: null) is still a 200.
400invalid_requestMalformed body — e.g. missing platform, post_ref, or replier_handle, or a max_chars outside 20–2200. Nothing is billed.
401invalid_tokenMissing, invalid, or revoked connector token.
402insufficient_tokensThe owner's wallet can't cover this generation — no model call was made. Top up. See Billing.
422validation_errorA field failed validation.
429rate_limitedRate limited; honor the Retry-After header.
500internal_errorUnexpected server error.

POST /comments/engage ​

Join a conversation between other people, under a post the character may never have touched. This is the third comment motion: /comments starts from us, /comments/reply starts from someone answering us, and this one starts from a thread we are not in and nobody has addressed us in.

Two consequences, and together they are the whole endpoint:

  • You must send the thread. We have no rows for comments we never saw, so what you send is the character's only context. Send the ancestor chain root → target at minimum; the whole visible thread is better, because that is what the selector reads.
  • Abstaining is the normal outcome, not an error. Omit target_comment_ref and the character picks the one comment worth answering — or decides that none of it is its business. Branch on reply being present, never on the 200.

Why picking a comment is its own step

Measured over 180 real generations: a single call that both picks a comment and writes the reply cannot abstain. Handed a thread that was none of the character's business, it answered anyway 10 times out of 10. So selection is its own cheap temperature-0 call, NONE is a first-class answer to it, and an abstention never reaches the writer at all. That is why you should expect abstentions — they are the mechanism working, not a misconfiguration.

Request body ​

NameTypeRequiredDescription
platformstringYesThe platform the thread is on, e.g. "reddit". Selects the bound workflow.
post_refstringYesThe platform's unique id or URL for the post the thread sits under. Threads are post-anchored, so this is also what max_engagements_per_post counts against.
threadobject[]YesThe comments you can see — see the thread comment object. Must be non-empty.
postobjectNoThe post object — caption, image URLs, and author handle. Usually worth sending: the character has never seen this post.
target_comment_refstringNoAnswer this comment. Must be one of the comment_refs in thread (400 otherwise). Omit it and the character picks one, or abstains.
max_charsintegerNoLength limit for this reply, 20–2200, as on POST /comments.

The thread comment object ​

FieldTypeRequiredDescription
comment_refstringYesThe platform's unique id for this comment. It is what comes back as target_comment_ref, so send the id you can post a reply against.
author_handlestringNoWho wrote it.
textstringNoWhat it says.
depthintegerNoNesting depth, 0 for a top-level comment. Indents the thread the selector reads.
parent_refstringNoThe comment this one replies to. Supply it on any row and we follow the real ancestor chain root → target; supply it on none and we take the comments in the order you sent them (which is the order every platform's API hands them to you).

Response ​

FieldTypeDescription
okbooleanAlways true on success.
decisionstring"comment_reply", "skip", or "bow_out" — the same vocabulary as /comments/reply. There is deliberately no drive_to_dm_nudge on this path.
replystring | nullThe text to post as a reply to target_comment_ref. null on every skip.
target_comment_refstring | nullWhich comment the reply answers. Post it as a reply to that comment — not as a top-level comment, and not to the post's author. null when nothing was answered.
target_author_handlestringThe handle of whoever wrote the target comment. Present when a reply was generated.
reasonstringWhy this comment was chosen ("caller-supplied target" when you named one), or, on a skip, which rule fired — see Skip reasons.
awareobjectA cross-surface awareness summary — what the character already knows about the target's author from DMs and other threads. Informational; treat its inner fields as illustrative, not a stable contract.
recognized_leadbooleantrue when the target's author is a Person the character already knows from another surface.
thread_idstring | nullThe comment thread for this post — created on the first engage, even though the character never commented on the post itself.
max_charsintegerThe length limit reply was held to. Present when a reply was generated. See Comment length.
truncatedbooleantrue when we shortened reply to fit and it reads as unfinished (no terminal punctuation, emoji or closing bracket/quote). Present when a reply was generated.
explainstringPlain-English detail on a branch that generated nothing. On no_target_selected it carries the selector's own words for why it passed; on empty_generation it names the step that left nothing.
diagnosticsobjectOnly on empty_generation: error, finish_reason, raw_chars, cleaned_chars and assembled_chars for the writer call. Counts only, no model text.
ignored / ignore_reasonstringPresent when comments are switched off for this workflow ("comments_disabled"); no reply is generated.
paused / pause_reason—Present when the character is over the plan's character cap; no reply is generated.
json
{
  "data": {
    "ok": true,
    "decision": "comment_reply",
    "reply": "backer rod first, then caulk over it. half an inch is way too much for caulk alone.",
    "target_comment_ref": "t1_a3",
    "target_author_handle": "drywall_dave",
    "reason": "repeats a myth they know is wrong",
    "aware": { "known": false, "cross_surface": "", "multi_thread": "", "recalled_count": 0 },
    "recognized_lead": false,
    "thread_id": "thr_1d9e3f04",
    "max_chars": 150,
    "truncated": false
  },
  "request_id": "req_1a77c0de"
}

An abstention — the common case. Post nothing:

json
{
  "data": {
    "ok": true,
    "decision": "skip",
    "reply": null,
    "target_comment_ref": null,
    "reason": "no_target_selected",
    "explain": "whole thread is foreign to them"
  },
  "request_id": "req_0b3e91ca"
}

No drive-to-DM on this path

/comments/engage never returns drive_to_dm_nudge, on purpose. Thread warmth seeds from the Person's global baseline and a recorded purchase slams that to 70 — so a known buyer would get "dm me" on the character's first public reply to them, under a stranger's post. Nudges belong in the character's own threads, via /comments/reply.

Skip reasons ​

reason names the rule that fired. Every one of these returns 200 with reply: null.

reasondecisionWhat happened
engage_disabledskipengage_enabled is off for this workflow. (Comments switched off entirely answer with ignore_reason: "comments_disabled" instead.)
empty_threadskipthread arrived with nothing usable in it.
thread_too_deepskipThe conversation is nested deeper than engage_max_thread_depth (default 8). Nobody is reading that far down. Depth comes from each comment's depth, or from parent_ref; sending many top-level comments is width, not depth, and does not trip it.
max_engagements_per_postskipThe character has already joined max_engagements_per_post threads under this post (default 2).
no_target_selectedskipThe abstention. Nothing in the thread was this character's to answer. explain carries the selector's reason; a selector call that failed outright reads selector_unavailable — we abstain rather than guess.
already_engaged_this_commentskipThe character has already replied to this exact comment. Safe to re-send the same thread as often as you like.
target_too_shortskipThe target is shorter than engage_min_target_chars (default 40). "this", "lol", "^" and a bare link give the writer nothing to answer, and it falls back to agreeing.
target_is_usskipThe target's author is the character itself — your own posted comments come back in a fetched thread.
hostile/bot-accusationbow_outThe comment is hostile or calls the character a bot. Post nothing and stop engaging here.
empty_generationskipThe writer produced nothing usable once links were stripped and the length limit applied. explain says which step left nothing (the model call failed, it returned no text, its text was all markup, or all links and handles), and diagnostics carries the counts behind it: error, finish_reason, raw_chars, cleaned_chars, assembled_chars. It never contains the model's own text.
writer_declinedskipThe writer chose not to answer this comment (it said it has nothing to add or that the topic is outside its subjects), so there is no reply to post. target_comment_ref is set to the comment it was asked to answer, so you can mark it as handled and not send it again.

Metered — but only when it speaks. Every rule above except no_target_selected and empty_generation is a deterministic check that runs before any model call. no_target_selected costs only the small temperature-0 selector call, and it is not billed: the charge fires only when a reply comes back. A wallet that can't cover a generation returns 402 before any model call. See Billing.

Examples ​

bash
curl -X POST https://api-talk.fluidvip.com/api/v1/characters/comments/engage \
  -H "X-Connector-Token: ftc_..." \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "reddit",
    "post_ref": "t3_1abc234",
    "post": { "caption": "gap between tub and tile keeps cracking", "author_handle": "newhomeowner" },
    "thread": [
      { "comment_ref": "t1_a1", "author_handle": "newhomeowner", "text": "its about half an inch wide", "depth": 0 },
      { "comment_ref": "t1_a3", "author_handle": "drywall_dave", "parent_ref": "t1_a1", "depth": 1,
        "text": "just run a thick bead of silicone over it, it will hold fine at that width" }
    ]
  }'
python
res = ft.comment_engage(
    platform="reddit",
    post_ref="t3_1abc234",
    post={"caption": "gap between tub and tile keeps cracking", "author_handle": "newhomeowner"},
    thread=[
        {"comment_ref": "t1_a1", "author_handle": "newhomeowner", "depth": 0,
         "text": "its about half an inch wide"},
        {"comment_ref": "t1_a3", "author_handle": "drywall_dave", "parent_ref": "t1_a1", "depth": 1,
         "text": "just run a thick bead of silicone over it, it will hold fine at that width"},
    ],
)

# Abstaining is normal: branch on `reply`, not on the call having succeeded.
if res.reply:
    publish_reply_on_reddit(res.target_comment_ref, res.reply)  # reply to THAT comment
else:
    log.info("engage passed on this thread: %s", res.reason)
typescript
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" },
  ],
});

// Abstaining is normal: branch on `reply`, not on the call having succeeded.
if (res.reply) {
  await publishReplyOnReddit(res.target_comment_ref!, res.reply); // reply to THAT comment
} else {
  console.log("engage passed:", res.reason);
}

The SDK post object is passed through as-is, so its fields stay snake_case (image_urls, author_handle) even in TypeScript, where the rest of the arguments are camelCase.

Every branch is reachable for free with mode: "mock": post_ref: "test_engage_skip" abstains, test_engage_hostile bows out, test_engage_deep returns thread_too_deep, and any other test_ ref picks the first comment out of the thread you sent.

Status codes ​

StatuscodeWhen
200—Reply generated, a skip (reply: null), or a safe no-op (comments off, or plan-paused).
400invalid_requestMalformed body — missing platform or post_ref, an empty thread, a target_comment_ref that is not in the thread you sent, or a max_chars outside 20–2200.
401invalid_tokenMissing, invalid, or revoked connector token.
402insufficient_tokensThe owner's wallet can't cover this generation — no model call was made. Top up. See Billing.
403forbiddenThe binding is disabled.
404not_foundNo workflow is bound for this platform on this character.
422validation_errorA field failed validation — e.g. a thread row with no comment_ref, or thread missing entirely.
429rate_limitedRate limited; honor the Retry-After header. Engage shares the threaded-reply generation bucket with /comments/reply.
500internal_errorUnexpected server error.

POST /comments/media ​

Upload a post's image and get back a permanent URL the vision model can fetch. Bytes in, URL out — there is no presigned upload link to request first.

Use it when a post's own image host refuses the model provider (vision.seen: false with reason: "vision_failed"), or unconditionally if you'd rather not depend on the platform's CDN at all. The returned URL goes in post.image_urls on POST /comments.

Not the same as POST /inbound-media. That one is for a lead-sent DM photo and files into the character's "<name> — Inbound" folder. A public post's image is not inbound DM media, so it gets its own "<name> — Comments" folder — otherwise your post images appear in the dashboard as photos a lead sent you.

This endpoint runs no model, so it is not billed and does not consume the per-endpoint generation rate limit — only the overall per-token budget. See Rate limits.

Request body ​

NameTypeRequiredDescription
platformstringYesThe platform the post is on, e.g. "reddit".
post_refstringYesThe platform's unique id or URL for the post the image belongs to.
data_b64stringYesThe image bytes, base64-encoded. Max 10 MB decoded.
filenamestringNoOriginal filename; used for the stored file's name.
content_typestringNoMIME type of the bytes. We sniff the real type from the bytes and the bytes win when the two disagree, so a wrong label can't misroute vision.

Response ​

FieldTypeDescription
okbooleanAlways true on success.
urlstring | nullThe permanent URL to put in post.image_urls. null on the two no-op branches below.
file_idstringThe stored file's id in your workspace.
post_refstringEchoed back, so a batched caller can match responses to posts.
ignored / ignore_reasonstringPresent when comments are switched off for this workflow — nothing was stored.
paused / pause_reason—Present when the character is over the plan's cap — nothing was stored.
explainstringPlain-English reason, on either no-op branch.

Treat a null url as "post nothing"

Both no-op branches return HTTP 200. As everywhere on this surface, 200 does not mean "there is something to use" — branch on url being present, not on the status code.

Examples ​

bash
curl -X POST https://api-talk.fluidvip.com/api/v1/characters/comments/media \
  -H "X-Connector-Token: ftc_..." \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "reddit",
    "post_ref": "t3_1abc234",
    "data_b64": "iVBORw0KGgoAAAANSUhEUg...",
    "filename": "post.png",
    "content_type": "image/png"
  }'

The SDKs take the raw bytes and base64 them for you (ft.comment_media / ft.commentMedia).

python
import requests
from fluidtalk import FluidTalk

ft = FluidTalk(token="ftc_...")

def commented(image_urls):
    return ft.comment(platform="reddit", post_ref=post.id, caption=post.body,
                      image_urls=image_urls, author_handle=post.author)

res = commented([post.image_url])                # the platform's own CDN URL

# Did the model actually see it? If not, rehost the bytes and try once more.
if res.vision and not res.vision.seen:
    up = ft.comment_media(platform="reddit", post_ref=post.id,
                          data=requests.get(post.image_url).content,   # your fetch, not ours
                          filename="post.png")
    if up.url:
        res = commented([up.url])

if res.comment:
    publish_comment_on_reddit(res.comment)       # you post it on the platform
typescript
import { FluidTalk } from "fluidtalk";

const ft = new FluidTalk({ token: "ftc_..." });

const commented = (imageUrls: string[]) => ft.comment({
  platform: "reddit", postRef: post.id, caption: post.body,
  imageUrls, authorHandle: post.author,
});

let res = await commented([post.imageUrl]);

if (res.vision && !res.vision.seen) {
  const bytes = await (await fetch(post.imageUrl)).arrayBuffer();  // your fetch, not ours
  const up = await ft.commentMedia({
    platform: "reddit", postRef: post.id, data: bytes, filename: "post.png",
  });

  if (up.url) res = await commented([up.url]);
}

if (res.comment) await publishCommentOnReddit(res.comment); // you post it

Status codes ​

StatuscodeWhen
200—Stored (url set), or a safe no-op with url: null (comments off, or plan-paused).
400invalid_requestMissing post_ref, missing/invalid data_b64.
401invalid_tokenMissing, invalid, or revoked connector token.
403forbiddenThe binding is disabled.
404not_foundNo workflow is bound for this platform on this character.
413payload_too_largeDecoded bytes exceed 10 MB.
429rate_limitedRate limited; honor the Retry-After header.
502upstream_errorStorage rejected the upload. Safe to retry.
503service_unavailableCloud storage isn't configured on this deployment. GET /self-test reports this as comment_media_storage before you send any bytes.

Errors ​

The SDKs raise typed errors — AuthError (401), PaymentRequiredError (402), PermissionError (403), NotFoundError (404), ConflictError (409), ValidationError (422), RateLimitError (429), and ApiError for anything else. See Errors for the full error model and Python / TypeScript for the typed exceptions.


  • Chat — drive a DM conversation; replies arrive as bubbles.
  • Concepts — the Person model and cross-surface awareness behind recognized_lead / aware.
  • Multi-account dedup — running several accounts of one character on a platform.
  • Billing — how generated comments and replies are metered.
  • Errors — the full error model and SDK error types.
  • API Reference index — all endpoints at a glance.

FluidTalk Characters API — part of the Fluidvip ecosystem.