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.
Method
Path
Purpose
POST
/comments
Generate a top-level comment for a post
POST
/comments/reply
Generate a threaded reply to a reply
POST
/comments/engage
Join a thread between other people — reply to someone else's comment
POST
/comments/media
Rehost 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.
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.
Field
Type
Description
caption
string
The post's caption text.
image_urls
string[]
URLs of the post's images. Each is described via vision so the comment can react to the picture, not just the words.
author_handle
string
The @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.
POST /comments, /comments/reply and /comments/engage all hold the generated text to one limit, the first of:
max_chars in the request body — an integer from 20 to 2200, for this call only;
the workflow's max_comment_chars comments setting, when it is set (null or 0 means unset);
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.
platform
Default
Ceiling
instagram
150
2200
tiktok
150
150
x, twitter, x_twitter
150
280
reddit
150
2200
threads
150
500
any other
150
2200
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.
The comment text to post on the platform. Absent on the not-configured no-op.
aware
object
A 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_lead
boolean
true when the post author / commenter is a Person the character already knows from another surface.
thread_id
string
Present once a comment thread exists for this post. Tracked for you — you don't pass it back.
max_chars
integer
The length limit comment was held to. Present when a comment was written. See Comment length.
truncated
boolean
true 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.
ignored
string
Present 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_tokensbefore any model call (you're never charged for a refused generation). See Billing.
from fluidtalk import FluidTalkft = 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}
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.
What the character chose to do: "comment_reply", "drive_to_dm_nudge", "skip", or "bow_out".
reply
string | null
The reply text to post, or null when the character chose not to reply (a "skip" decision).
reason
string
On 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.
aware
object
Optional cross-surface awareness summary — what the character already knows about this Person. Informational; treat inner fields as illustrative.
max_chars
integer
The length limit reply was held to. Present on "comment_reply" and "drive_to_dm_nudge", absent on "skip" and "bow_out". See Comment length.
truncated
boolean
true 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:
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.
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}
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.
The platform the thread is on, e.g. "reddit". Selects the bound workflow.
post_ref
string
Yes
The 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.
The 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_handle
string
No
Who wrote it.
text
string
No
What it says.
depth
integer
No
Nesting depth, 0 for a top-level comment. Indents the thread the selector reads.
parent_ref
string
No
The 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).
"comment_reply", "skip", or "bow_out" — the same vocabulary as /comments/reply. There is deliberately no drive_to_dm_nudge on this path.
reply
string | null
The text to post as a reply to target_comment_ref. null on every skip.
target_comment_ref
string | null
Which 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_handle
string
The handle of whoever wrote the target comment. Present when a reply was generated.
reason
string
Why this comment was chosen ("caller-supplied target" when you named one), or, on a skip, which rule fired — see Skip reasons.
aware
object
A 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_lead
boolean
true when the target's author is a Person the character already knows from another surface.
thread_id
string | null
The comment thread for this post — created on the first engage, even though the character never commented on the post itself.
max_chars
integer
The length limit reply was held to. Present when a reply was generated. See Comment length.
truncated
boolean
true 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.
explain
string
Plain-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.
diagnostics
object
Only on empty_generation: error, finish_reason, raw_chars, cleaned_chars and assembled_chars for the writer call. Counts only, no model text.
ignored / ignore_reason
string
Present 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"}
/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.
reason names the rule that fired. Every one of these returns 200 with reply: null.
reason
decision
What happened
engage_disabled
skip
engage_enabled is off for this workflow. (Comments switched off entirely answer with ignore_reason: "comments_disabled" instead.)
empty_thread
skip
thread arrived with nothing usable in it.
thread_too_deep
skip
The 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_post
skip
The character has already joined max_engagements_per_post threads under this post (default 2).
no_target_selected
skip
The 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_comment
skip
The character has already replied to this exact comment. Safe to re-send the same thread as often as you like.
target_too_short
skip
The 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_us
skip
The target's author is the character itself — your own posted comments come back in a fetched thread.
hostile/bot-accusation
bow_out
The comment is hostile or calls the character a bot. Post nothing and stop engaging here.
empty_generation
skip
The 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_declined
skip
The 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.
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 commentelse: 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.
Reply generated, a skip (reply: null), or a safe no-op (comments off, or plan-paused).
400
invalid_request
Malformed 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.
401
invalid_token
Missing, invalid, or revoked connector token.
402
insufficient_tokens
The owner's wallet can't cover this generation — no model call was made. Top up. See Billing.
403
forbidden
The binding is disabled.
404
not_found
No workflow is bound for this platform on this character.
422
validation_error
A field failed validation — e.g. a thread row with no comment_ref, or thread missing entirely.
429
rate_limited
Rate limited; honor the Retry-After header. Engage shares the threaded-reply generation bucket with /comments/reply.
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.
The permanent URL to put in post.image_urls. null on the two no-op branches below.
file_id
string
The stored file's id in your workspace.
post_ref
string
Echoed back, so a batched caller can match responses to posts.
ignored / ignore_reason
string
Present 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.
explain
string
Plain-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.
The SDKs take the raw bytes and base64 them for you (ft.comment_media / ft.commentMedia).
python
import requestsfrom fluidtalk import FluidTalkft = 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
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.
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:
Every response is wrapped in the standard envelope — the real payload is under
data, alongside arequest_id(also returned as theX-Request-Idheader). The shapes below describe the innerdata. See Errors for the envelope and error model.POST/commentsPOST/comments/replyPOST/comments/engagePOST/comments/mediaOne 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_leadand theawaresummary reflect what the character already knows about this Person from elsewhere. See Concepts.The
postobject These endpoints take an optional
postobject 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.captionimage_urlsauthor_handle@usernameof whoever authored the post.On
POST /commentsthepostobject carries the post being commented on. OnPOST /comments/replyandPOST /comments/engageit is optional — pass it only when the thread is new and the character hasn't seen this post yet, which on/comments/engageis 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.orgrenders in any browser and comes backvision_failed).When the fetch fails,
vision.seenisfalseand the comment is written from the caption alone — confidently, and sometimes about details that were never in the picture. Always logvision.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/mediaand put the URL it returns inimage_urlsinstead.Comment length
POST /comments,/comments/replyand/comments/engageall hold the generated text to one limit, the first of:max_charsin the request body — an integer from20to2200, for this call only;max_comment_charscomments setting, when it is set (nullor0means unset);150characters on every platform.Whichever applies is lowered to the platform's ceiling when it is above it; the ceiling also knows
ig,instaandx_twitter. Until 2026-10-12 the default was set per platform (Instagram 220, X 280, Reddit 500); it is now 150 everywhere.platforminstagramtiktokx,twitter,x_twitterredditthreadsThe 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) andtruncated(trueonly 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 isfalse). They are absent on askip, abow_outand the no-op branches. Amax_charsoutside20–2200is a400before anything is billed. In the TypeScript SDK it ismaxCharsand in the Python SDKmax_chars, both from 2.8.0.POST /commentsGenerate a top-level public comment for a post. You post the returned text on the platform.
Request body
platform"instagram". Selects the bound workflow.post_refpostpostobject — caption, image URLs, and author handle.max_chars20–2200, overriding the workflow'smax_comment_chars; never above the platform's ceiling. See Comment length.Response
oktrueon success.commentawarerecognized_leadtruewhen the post author / commenter is a Person the character already knows from another surface.thread_idmax_charscommentwas held to. Present when a comment was written. See Comment length.truncatedtruewhen we shortenedcommentto fit and it reads as unfinished (no terminal punctuation, emoji or closing bracket/quote). Present when a comment was written.ignored"comments not configured"); nocommentis returned.Examples
Status codes
code200ignored: "comments not configured").400invalid_requestplatformorpost_ref, or amax_charsoutside20–2200. Nothing is billed.401invalid_token402insufficient_tokens422validation_errorpost.image_urlsisn't a list).429rate_limitedRetry-Afterheader.500internal_errorPOST /comments/replyGenerate 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
platform"instagram".post_refreplier_handle@usernameof whoever replied.reply_textparent_comment_refpostpostobject — pass it only when the thread is new and the character hasn't seen this post yet.max_chars20–2200, as onPOST /comments.Response
oktrueon success.decision"comment_reply","drive_to_dm_nudge","skip", or"bow_out".replynullwhen the character chose not to reply (a"skip"decision).reason"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.awaremax_charsreplywas held to. Present on"comment_reply"and"drive_to_dm_nudge", absent on"skip"and"bow_out". See Comment length.truncatedtruewhen we shortenedreplyto fit and it reads as unfinished (no terminal punctuation, emoji or closing bracket/quote). Present exactly whenmax_charsis.When the character decides to stay quiet,
replyisnull— post nothing:Examples
Status codes
code200"skip"decision (reply: null) is still a200.400invalid_requestplatform,post_ref, orreplier_handle, or amax_charsoutside20–2200. Nothing is billed.401invalid_token402insufficient_tokens422validation_error429rate_limitedRetry-Afterheader.500internal_errorPOST /comments/engageJoin a conversation between other people, under a post the character may never have touched. This is the third comment motion:
/commentsstarts from us,/comments/replystarts 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:
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.target_comment_refand the character picks the one comment worth answering — or decides that none of it is its business. Branch onreplybeing present, never on the200.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,
NONEis 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
platform"reddit". Selects the bound workflow.post_refmax_engagements_per_postcounts against.threadpostpostobject — caption, image URLs, and author handle. Usually worth sending: the character has never seen this post.target_comment_refcomment_refs inthread(400otherwise). Omit it and the character picks one, or abstains.max_chars20–2200, as onPOST /comments.The thread comment object
comment_reftarget_comment_ref, so send the id you can post a reply against.author_handletextdepth0for a top-level comment. Indents the thread the selector reads.parent_refResponse
oktrueon success.decision"comment_reply","skip", or"bow_out"— the same vocabulary as/comments/reply. There is deliberately nodrive_to_dm_nudgeon this path.replytarget_comment_ref.nullon every skip.target_comment_refnullwhen nothing was answered.target_author_handlereason"caller-supplied target"when you named one), or, on a skip, which rule fired — see Skip reasons.awarerecognized_leadtruewhen the target's author is a Person the character already knows from another surface.thread_idmax_charsreplywas held to. Present when a reply was generated. See Comment length.truncatedtruewhen we shortenedreplyto fit and it reads as unfinished (no terminal punctuation, emoji or closing bracket/quote). Present when a reply was generated.explainno_target_selectedit carries the selector's own words for why it passed; onempty_generationit names the step that left nothing.diagnosticsempty_generation:error,finish_reason,raw_chars,cleaned_charsandassembled_charsfor the writer call. Counts only, no model text.ignored/ignore_reason"comments_disabled"); no reply is generated.paused/pause_reasonAn abstention — the common case. Post nothing:
No drive-to-DM on this path
/comments/engagenever returnsdrive_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
reasonnames the rule that fired. Every one of these returns200withreply: null.reasondecisionengage_disabledskipengage_enabledis off for this workflow. (Comments switched off entirely answer withignore_reason: "comments_disabled"instead.)empty_threadskipthreadarrived with nothing usable in it.thread_too_deepskipengage_max_thread_depth(default 8). Nobody is reading that far down. Depth comes from each comment'sdepth, or fromparent_ref; sending many top-level comments is width, not depth, and does not trip it.max_engagements_per_postskipmax_engagements_per_postthreads under this post (default 2).no_target_selectedskipexplaincarries the selector's reason; a selector call that failed outright readsselector_unavailable— we abstain rather than guess.already_engaged_this_commentskiptarget_too_shortskipengage_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_usskiphostile/bot-accusationbow_outempty_generationskipexplainsays which step left nothing (the model call failed, it returned no text, its text was all markup, or all links and handles), anddiagnosticscarries the counts behind it:error,finish_reason,raw_chars,cleaned_chars,assembled_chars. It never contains the model's own text.writer_declinedskiptarget_comment_refis set to the comment it was asked to answer, so you can mark it as handled and not send it again.Examples
Every branch is reachable for free with
mode: "mock":post_ref: "test_engage_skip"abstains,test_engage_hostilebows out,test_engage_deepreturnsthread_too_deep, and any othertest_ref picks the first comment out of the thread you sent.Status codes
code200reply: null), or a safe no-op (comments off, or plan-paused).400invalid_requestplatformorpost_ref, an emptythread, atarget_comment_refthat is not in thethreadyou sent, or amax_charsoutside20–2200.401invalid_token402insufficient_tokens403forbidden404not_found422validation_errorthreadrow with nocomment_ref, orthreadmissing entirely.429rate_limitedRetry-Afterheader. Engage shares the threaded-reply generation bucket with/comments/reply.500internal_errorPOST /comments/mediaUpload 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: falsewithreason: "vision_failed"), or unconditionally if you'd rather not depend on the platform's CDN at all. The returned URL goes inpost.image_urlsonPOST /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
platform"reddit".post_refdata_b64filenamecontent_typeResponse
oktrueon success.urlpost.image_urls.nullon the two no-op branches below.file_idpost_refignored/ignore_reasonpaused/pause_reasonexplainTreat a null
urlas "post nothing"Both no-op branches return HTTP 200. As everywhere on this surface,
200does not mean "there is something to use" — branch onurlbeing present, not on the status code.Examples
The SDKs take the raw bytes and base64 them for you (
ft.comment_media/ft.commentMedia).Status codes
code200urlset), or a safe no-op withurl: null(comments off, or plan-paused).400invalid_requestpost_ref, missing/invaliddata_b64.401invalid_token403forbidden404not_found413payload_too_large429rate_limitedRetry-Afterheader.502upstream_error503service_unavailableGET /self-testreports this ascomment_media_storagebefore you send any bytes.Errors
The SDKs raise typed errors —
AuthError(401),PaymentRequiredError(402),PermissionError(403),NotFoundError(404),ConflictError(409),ValidationError(422),RateLimitError(429), andApiErrorfor anything else. See Errors for the full error model and Python / TypeScript for the typed exceptions.Related
recognized_lead/aware.