A FluidTalk character doesn't only run DMs — it can also speak in public, commenting on a post and then holding a threaded back-and-forth underneath that comment. This guide shows the three comment motions: writing a top-level comment on a post (POST /comments), replying to the replies that land under it (POST /comments/reply), to the original poster or to other commenters, as many times as the thread keeps going — and joining a conversation between other people that the character was never part of (POST /comments/engage).
As everywhere in the Characters API, you own the platform I/O: the API generates the character's comment text and tracks the relationship; your connector posts it. The API never posts anything itself.
All calls go to the production base URL https://api-talk.fluidvip.com/api/v1/characters and authenticate with your per-character connector token in the X-Connector-Token header (see authentication.md). The token is the character — there is no character_id; you name the platform in each request body, and the same token works for every platform that character runs on.
For the Person model behind cross-surface awareness (the same lead who DMs and comments is one persona), see concepts.md. For the exact field-by-field request/response shapes, see reference/comments.md and reference/comments.md#post-comments-reply. Every response is wrapped in the standard { "data": …, "request_id": "req_…" } envelope and carries an X-Request-Id header — the shapes below show the inner data.
post a platform post (post_ref + caption/images/author)├── character's comment POST /comments → the text you post under the post│ ├── reply (the OP) POST /comments/reply → the character replies back│ └── reply (someone else) POST /comments/reply → the character replies, thread-aware└── someone else's thread POST /comments/engage → the character joins a conversation └── …between two other people, under a post it never commented on
A comment is a single top-level comment the character writes for a post. You identify the post by post_ref (the platform's unique id or URL) and pass what context you have about it.
A thread is the character's comment plus the replies under it. Each incoming reply is a separate POST /comments/reply call. The character can reply more than once per thread, and to different commenters in the same thread — it stays consistent across the whole thread, and across other threads and DMs via the shared Person.
An engagement is the character replying to somebody else's comment, in a conversation it was never part of. Nobody addressed it, so you supply the thread — see Engage.
Generating a comment or a reply is a metered turn — see Billing. If the character owner's wallet can't cover it you get 402before any model call, so you are never charged for a comment you didn't get.
from fluidtalk import FluidTalkft = FluidTalk(token="ftc_8f3c...")result = ft.comment( platform="instagram", post_ref="https://instagram.com/p/Cxy12Ab34Cd/", caption="golden hour on the rooftop ✨", image_urls=["https://cdn.example.com/post-1.jpg"], author_handle="mark",)print(result.comment) # post this text on the platformprint(result.recognized_lead) # True if the author is already a known Person
typescript
import { FluidTalk } from "fluidtalk";const ft = new FluidTalk({ token: "ftc_8f3c..." });const result = await ft.comment({ platform: "instagram", postRef: "https://instagram.com/p/Cxy12Ab34Cd/", caption: "golden hour on the rooftop ✨", imageUrls: ["https://cdn.example.com/post-1.jpg"], authorHandle: "mark",});console.log(result.comment); // post this text on the platformconsole.log(result.recognizedLead); // true if the author is already a known Person
The SDKs take the post fields flat (caption, image_urls/imageUrls, author_handle/authorHandle) and assemble the nested post object for you. Over raw HTTP, send them nested under post as shown in the curl example.
The response:
json
{ "data": { "ok": true, "comment": "obsessed with this fit 😍 where'd you get the jacket?", "recognized_lead": true, "aware": { "...": "cross-surface awareness summary" }, "thread_id": "cth_7b21…", "max_chars": 150, "truncated": false }, "request_id": "req_2c9f…"}
comment is the text to post. recognized_lead is true when the post's author is a Person the character already knows. aware is a compact summary of what the character knows about them across surfaces (see below). thread_id is present once a thread exists, so you can correlate the replies that follow. max_chars is the length limit the comment was held to and truncated says whether we had to shorten it and it now reads as unfinished — see How long a comment can be.
Send post.image_urls whenever the post has pictures — a comment written with them reads noticeably better than one written off the caption alone, because the character can react to what is actually shown.
There is one trap, and it fails quietly. FluidTalk does not fetch the image; the model provider does, from the URL you send. So "the URL is public" is not enough — the host has to serve the provider's fetcher, and some public hosts refuse it outright. upload.wikimedia.org is the clearest example: it renders in any browser and comes back unreadable.
When the fetch fails you still get a comment. It is just written blind, and a character asked to react to a picture it cannot see will sometimes describe one that was never there.
You get back a permanent url — put it in post.image_urls and the comment is written with the picture. It is bytes in, URL out: there is no upload link to request first. The endpoint runs no model, so it is not billed. Full shape in the Comments reference.
This is not /inbound-media
POST /inbound-media exists for a photo a lead sent you in a DM, and files into the character's "<name> — Inbound" folder. A public post's image is not inbound DM media — sending it there makes your post images show up in the dashboard as photos a lead sent. POST /comments/media keeps them in their own "<name> — Comments" folder.
POST /comments/reply generates the character's reply to a reply under its comment. It can fire multiple times in one thread — to the original poster or to other commenters who join in.
Name
Type
Required
Description
platform
string
Yes
The platform the thread is on.
post_ref
string
Yes
The post whose thread this reply belongs to.
replier_handle
string
Yes
The @username of whoever replied.
reply_text
string
No
What they said — the text the character is responding to.
parent_comment_ref
string
No
The platform ref of the comment being replied to, to thread the reply correctly.
post
object
No
Post context (same shape as above), if the thread is new to the character.
result = ft.comment_reply( platform="instagram", post_ref="https://instagram.com/p/Cxy12Ab34Cd/", replier_handle="mark", reply_text="you look unreal here 😍", parent_comment_ref="ig_comment_17985…",)if result.reply is not None: # None when the character chooses to skip print(result.decision, result.reply)
typescript
const result = await ft.commentReply({ platform: "instagram", postRef: "https://instagram.com/p/Cxy12Ab34Cd/", replierHandle: "mark", replyText: "you look unreal here 😍", parentCommentRef: "ig_comment_17985…",});if (result.reply !== null) { // null when the character chooses to skip console.log(result.decision, result.reply);}
The response carries the character's decision and, when it decides to speak, the reply text:
Reply in-thread. reply holds the text to post under the thread.
drive_to_dm_nudge
Reply, but nudge the conversation toward DMs. Post reply, then continue in DMs via POST /chat.
skip
The character chose not to reply. reply is null — post nothing.
bow_out
The replier turned hostile or called the character a bot. reply is null, and the thread is marked BOWED_OUT so it stays quiet.
When reply is null, do not post anything. On a skip or bow_out the reason names the rule that fired — see the settings below, which is where each of those reasons comes from. aware is the same cross-surface summary returned by POST /comments.
Branch on decision, not on the presence of reply: the two nudge decisions both carry text and mean different things downstream.
Both motions above begin with the character: it comments, or somebody answers its comment. POST /comments/engage begins somewhere else entirely — with a conversation between other people, under a post the character may never have touched. Somebody is wrong about caulk, or asking a question your character can actually answer, and the character drops into their thread.
Nobody spoke to us. Everything that makes this endpoint different follows from that one fact.
1. Your connector must send the thread. For a reply we already hold the rows — our comment, who answered it, what they said. Here we have nothing: we never saw these comments, so what you put in thread 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 character reads when it decides which comment to answer.
2. Abstaining is the normal outcome, not an error. Leave target_comment_ref out and the character reads the thread, picks the one comment it would stop on — or decides none of it is its business and says nothing at all. That comes back as a 200 with reply: null and reason: "no_target_selected". It is the common case. Branch on reply being present, never on the call having succeeded.
Why picking is a separate step from writing. Measured over 180 real generations: a single call that both picks a comment and writes the reply cannot abstain. Given a thread that was none of the character's business, it replied anyway — 10 times out of 10, in a lane it knew nothing about. So the choice is its own cheap temperature-0 call, with "none" as a first-class answer. That is the only reason abstaining works at all, and it is why an abstention costs no generation and is never billed.
Name
Type
Required
Description
platform
string
Yes
The platform the thread is on.
post_ref
string
Yes
The post the thread sits under. Threads are post-anchored, so this is what max_engagements_per_post counts against.
thread
object[]
Yes
The comments you can see. Each is {comment_ref, author_handle, text, depth?, parent_ref?}.
post
object
No
Post context (same shape as above). Worth sending — the character has never seen this post.
target_comment_ref
string
No
Answer this comment. Omit it and the character picks one, or abstains.
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", "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" } ] }'
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"}, ],)if res.reply: # None whenever the character passed — the normal case post_reply(res.target_comment_ref, res.reply)
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" }, ],});if (res.reply) { // null whenever the character passed — the normal case await postReply(res.target_comment_ref!, res.reply);}
When the character does speak, it tells you which comment it is answering:
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", "recognized_lead": false, "thread_id": "thr_1d9e3f04", "max_chars": 150, "truncated": false }, "request_id": "req_1a77c0de"}
Post that text as a reply to target_comment_ref — not as a top-level comment on the post, and not to the post's author. And when it passes:
decision uses the same vocabulary as a reply — comment_reply, skip, bow_out — with one deliberate absence: engage never returns drive_to_dm_nudge. Thread warmth seeds from the Person's global baseline and a recorded purchase slams that to 70, so a known buyer would otherwise be told "dm me" on the character's very first public reply to them, under a stranger's post. Nudges belong in the character's own threads.
The other reason values you will see — thread_too_deep, max_engagements_per_post, target_too_short, already_engaged_this_comment, target_is_us, hostile/bot-accusation — come from the engage settings below, and each one is listed in full in the Comments reference.
Sending the same thread twice is safe
The character never answers the same comment twice (already_engaged_this_comment), and it will not join more than max_engagements_per_post threads under one post. So a connector that re-scans a post on every pass does not need its own bookkeeping — re-send what you can see and let the stop rules do the work.
Every comment, reply and engage reply is held to a length limit. Unless you change it, that is 150 characters on every platform. Until 2026-10-12 the default was set per platform (Instagram 220, X 280, Reddit 500); it is now 150 everywhere.
platform
Default
Most you can set
instagram
150
2200
tiktok
150
150
x, twitter, x_twitter
150
280
reddit
150
2200
threads
150
500
any other
150
2200
To change it, set Max comment length (max_comment_chars) in the workflow's comment settings, or send max_chars on a single call to any of the three endpoints. Both take 20–2200 characters; max_chars wins over the setting for that call. Either way the limit never goes above the platform's own ceiling (the last column), so max_chars: 1000 on TikTok still gives you 150. The limit is counted in Unicode code points.
The writer is told the limit before it writes. It is a ceiling, not a target: comments still come out as long as the thought needs, which is usually well under a high limit (measured on Flash-Lite: a limit of 1200 gave 260–410 characters, 600 gave 235–270). Use it to keep comments shorter, or to let them run longer than the 150-character default; it will not pad a comment to fill it. What comes back carries two fields, on every response that has generated text (comment, or a reply on comment_reply and drive_to_dm_nudge) and never on a skip or bow_out:
max_chars — the limit that was applied: yours, the setting's, or the default of 150.
truncated — true only when we shortened the text (it ran over the limit and 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 that a longer draft was shortened to is false. Over the limit, we cut at the last sentence end inside it when that keeps at least 40% of the limit, otherwise at the last comma, semicolon, colon or dash that keeps 40%, otherwise at the last space, and drop any trailing comma, colon or dash. A cut at a clause ends without a full stop, so it stays truncated: true.
A longer limit lets the writer write more, and a longer comment costs more. A max_chars outside 20–2200 is a 400 before anything is billed. In the TypeScript SDK it is maxChars on comment, commentReply and commentEngage, and in the Python SDK max_chars on comment, comment_reply and comment_engage, both from 2.8.0.
Every decision you get back is the product of the character's comment settings for that platform. They live on the platform's workflow — the Comments editor in the dashboard, next to the DM flow — and they are the reason two identical calls can behave differently on two platforms. You don't send them in a request — the one exception is the length limit, which max_chars can override for a single call. They are configuration, and this table is here so an unexpected skip is diagnosable rather than mysterious.
Setting
Default
What you observe
Enable
on
Off makes every endpoint on this page answer 200 with ignore_reason: "comments_disabled" and generate nothing. See below.
Mode
full_thread
comment_once posts the top-level comment and never replies: every /comments/reply returns skip · mode=comment_once. The inbound reply is still recorded.
Reply to strangers
on
Off means only the post's author gets replies; anyone else returns skip · reply_to_strangers=off.
Bow out on hostile
on
A bot accusation or insult returns bow_out · hostile/bot-accusation and marks the thread BOWED_OUT.
Max replies per thread
6
Once the character has replied this many times under one post: skip · max_replies_per_thread.
Max replies per author
3
Per person, within one thread: skip · max_replies_per_author.
Max replies per author (total)
0 = unlimited
Per person across every thread: skip · max_replies_per_author_total.
Drive to DM
on
Enables the drive_to_dm_nudge decision at all.
Drive to DM after warmth
35
How warm the public thread must get before that nudge fires. This is the thread's own warmth, not DM rapport.
Nudge once per person
on
The nudge fires once per Person in total, not once under every post they comment on.
Graduate strangers to leads
on
A warmed commenter becomes a real Person, so your POST /chat calls for them arrive with history.
Max comment length (max_comment_chars)
blank = 150
The longest comment, reply or engage reply, 20–2200 characters, never above the platform's ceiling. Blank (null) or 0 keeps the default of 150 characters. A max_chars on the call wins over it. A longer limit costs more per comment.
Comment hard rules (comment_hard_rules)
blank = off
Opt-in. Your rules, one per sentence or line, go into the writer's hard-rules block for top-level comments and replies, the way engage_prompt already does, so they hold far better than the same words appended to a comment prompt. Blank leaves the prompts unchanged.
Digits (digits)
off
Opt-in. Number words in the finished comment become digits ("thirty minutes" becomes "30 minutes"); "no one", "one of", "someone" and "once" are left alone.
Banned openers (banned_openers)
none
Opt-in list of words a comment may not open with, such as ["that", "honestly"]. A comment that opens with one is written again once; if the second also does, only that word is dropped.
Allow links in comments
off
Leave it off. On, the character may post a URL publicly — the single biggest shadowban trigger on every platform this runs on. The funnel moves people through the DM.
Engage enabled (engage_enabled)
on
Governs /comments/engage only. Off returns skip · engage_disabled — the other two endpoints keep working. On just means "don't refuse when the connector asks"; the character never joins a thread you didn't send.
Max engagements per post (max_engagements_per_post)
2
How many of other people's threads the character may join under one post: after that, skip · max_engagements_per_post. Low on purpose — six replies scattered across six threads under one post reads far more like a bot than six in one conversation.
Min target length (engage_min_target_chars)
40 chars
A comment shorter than this is never answered: skip · target_too_short. "this", "lol", "^" and a bare link give the writer nothing to answer, so it falls back to agreeing.
Max thread depth (engage_max_thread_depth)
8 levels
Refuse a conversation nested deeper than this — skip · thread_too_deep. Nobody is reading a 40-deep slapfight. Measured from each comment's depth when you send it, otherwise by following parent_ref. Width is not depth: forty top-level comments on one post are depth 1 and are fine.
The character's intent and extra style guidance for each kind of comment. The engage one (engage_prompt) is its own directive, not the reply one: nobody has spoken to the character, so it must not thank anyone for replying, greet, or refer to a comment of its own. Changes what it writes, never whether it writes.
A skip or bow_out is not an error and not a wasted charge: every stop rule above is a deterministic check that runs before any model call, so the character decided to stay quiet without generating anything. Engage's abstention (no_target_selected) is the one that does reach a model — the small temperature-0 call that picks a comment — and it is not billed either: the charge fires only when a reply comes back.
Three things the settings table does not say, each learned from running dozens of personas at once:
Only the first 6 facts reach a comment or a DM. The writer reads the character's self_facts first, then her constant lorebook entries, and keeps the first 6 (posts keep 12). A guard fact added as the 8th line ("never claims to be a nurse") is never seen. Put guards and the facts that matter most at the front of self_facts. Over MCP, create_character and update_character return a facts_note naming exactly the facts beyond the cut.
Quoted example phrases are reused literally. If a prompt, style_note or personality says sounds like "love this, so true", expect "love this, so true" under many posts. Describe the tone ("short, warm, one reaction, no question") instead of quoting lines. This applies to comment_prompt, reply_prompt, drive_prompt, engage_prompt, style_note and the character's personality and self_facts.
Copying a workflow copies the source persona's wording. Binding a platform from a template (bind_platform with template_engine_id) clones the template's comments.comment_prompt, reply_prompt, drive_prompt, engage_prompt, comments.style_note and posts.style_note too, so a new character inherits another character's subjects. The response lists them as copied_persona_text; pass blank_persona_text: true to start those fields blank (the built-in defaults then apply) on the new workflow. The source workflow is never touched.
This is the headline of the Characters API: one coherent persona everywhere. A lead is identified by (platform, handle), and the same lead who comments on a post and who slides into the DMs resolves to a single Person. The character remembers across surfaces — DMs ↔ comments — and across multiple posts and threads.
recognized_lead (on POST /comments) is true when the post's author_handle is already a known Person.
aware (on both endpoints) is a compact summary of what the character already knows about that Person — earlier DMs, prior comments, other threads — so a public reply lands consistently with whatever was said in private, and vice-versa.
You don't wire any of this up. As long as you pass the real author_handle / replier_handle, awareness is resolved for you. For the full model, see Core concepts.
When a thread is warming up, POST /comments/reply may return decision: "drive_to_dm_nudge". Post the reply text as usual — it gently steers the replier toward the DMs. When that person then messages the character, feed the inbound DM to POST /chat with the same(platform, handle). Because it's the same Person, the character picks the conversation up with full context from the public thread — no handoff state to manage on your side.
Comments are configured per character and per platform, and they are ON by default — a character with a bound workflow you have never opened the Comments settings on will comment. The setting exists so you can opt a platform out.
When someone has done that, every call you make here returns a 200 success envelope whose data is a no-op marker rather than a comment.
If your comment calls seem to do nothing, this is the first thing to check.
json
{ "data": { "ok": true, "ignored": "comments not configured", "ignore_reason": "comments_disabled", "comment": null, "explain": "Comments are switched OFF for workflow 'IG Flow' (character 'Ava', platform 'instagram') — comments are on by default, so this workflow has been set that way deliberately. Turn Comments back on in the workflow's settings; nothing you send to this endpoint will generate anything until you do." }, "request_id": "req_5a08…"}
This is not an error — there is no comment / reply field, so simply post nothing. It lets you call the comment endpoints unconditionally from your connector and let the dashboard decide whether the character speaks publicly on that platform.
Branch on ignore_reason, not on the explain prose. To check the flag before you write any code, call GET /self-test — it reports comments_enabled per binding.
Malformed body — e.g. missing post_ref or replier_handle, invalid JSON, or a max_chars outside 20–2200. Nothing is billed.
401
invalid_token
Missing, invalid, or revoked connector token.
402
insufficient_tokens
The character owner's wallet can't cover the generation. Returned before any model call — top up in the dashboard and retry.
403
forbidden
The token isn't allowed to act on this platform/surface.
404
not_found
The thread or referenced comment isn't yours, or doesn't exist.
422
validation_error
A field failed validation (e.g. image_urls not a list of strings).
429
rate_limited
Too many requests — honor the Retry-After header.
500
internal_error
Something broke on our side; retry with backoff.
Note that comments not configured is a 200 no-op (above), not a 404. The SDKs raise typed errors — PaymentRequiredError for 402, NotFoundError for 404, ValidationError for 422, and so on. See errors.md for the full error model and rate-limits.md for per-token limits.
Public comments & threaded replies
A FluidTalk character doesn't only run DMs — it can also speak in public, commenting on a post and then holding a threaded back-and-forth underneath that comment. This guide shows the three comment motions: writing a top-level comment on a post (
POST /comments), replying to the replies that land under it (POST /comments/reply), to the original poster or to other commenters, as many times as the thread keeps going — and joining a conversation between other people that the character was never part of (POST /comments/engage).As everywhere in the Characters API, you own the platform I/O: the API generates the character's comment text and tracks the relationship; your connector posts it. The API never posts anything itself.
All calls go to the production base URL
https://api-talk.fluidvip.com/api/v1/charactersand authenticate with your per-character connector token in theX-Connector-Tokenheader (see authentication.md). The token is the character — there is nocharacter_id; you name the platform in each request body, and the same token works for every platform that character runs on.How a comment thread works
post_ref(the platform's unique id or URL) and pass what context you have about it.POST /comments/replycall. The character can reply more than once per thread, and to different commenters in the same thread — it stays consistent across the whole thread, and across other threads and DMs via the shared Person.Comment on a post
POST /commentsgenerates a top-level comment for a post. Post the returnedcommenttext yourself.platform"instagram"). Selects the character's bound comments workflow.post_refpostmax_chars20–2200characters. See How long a comment can be.The
postobject:post.captionpost.image_urlspost.author_handle@usernameof whoever published the post — used for cross-surface recognition.The response:
commentis the text to post.recognized_leadistruewhen the post's author is a Person the character already knows.awareis a compact summary of what the character knows about them across surfaces (see below).thread_idis present once a thread exists, so you can correlate the replies that follow.max_charsis the length limit the comment was held to andtruncatedsays whether we had to shorten it and it now reads as unfinished — see How long a comment can be.Images on a post
Send
post.image_urlswhenever the post has pictures — a comment written with them reads noticeably better than one written off the caption alone, because the character can react to what is actually shown.There is one trap, and it fails quietly. FluidTalk does not fetch the image; the model provider does, from the URL you send. So "the URL is public" is not enough — the host has to serve the provider's fetcher, and some public hosts refuse it outright.
upload.wikimedia.orgis the clearest example: it renders in any browser and comes back unreadable.When the fetch fails you still get a comment. It is just written blind, and a character asked to react to a picture it cannot see will sometimes describe one that was never there.
So always look at
vision:visionimage_urls. The comment came from the caption alone.seen: truedescriptionis what the model saw.seen: false,reason: "vision_failed"seen: false,reason: "no_url"If a platform's image host refuses the provider, hand us the bytes instead:
You get back a permanent
url— put it inpost.image_urlsand the comment is written with the picture. It is bytes in, URL out: there is no upload link to request first. The endpoint runs no model, so it is not billed. Full shape in the Comments reference.This is not
/inbound-mediaPOST /inbound-mediaexists for a photo a lead sent you in a DM, and files into the character's"<name> — Inbound"folder. A public post's image is not inbound DM media — sending it there makes your post images show up in the dashboard as photos a lead sent.POST /comments/mediakeeps them in their own"<name> — Comments"folder.Reply in a thread
POST /comments/replygenerates the character's reply to a reply under its comment. It can fire multiple times in one thread — to the original poster or to other commenters who join in.platformpost_refreplier_handle@usernameof whoever replied.reply_textparent_comment_refpostmax_chars20–2200characters. See How long a comment can be.The response carries the character's decision and, when it decides to speak, the
replytext:The
decisiontells you what to do:decisioncomment_replyreplyholds the text to post under the thread.drive_to_dm_nudgereply, then continue in DMs viaPOST /chat.skipreplyisnull— post nothing.bow_outreplyisnull, and the thread is markedBOWED_OUTso it stays quiet.When
replyisnull, do not post anything. On askiporbow_outthereasonnames the rule that fired — see the settings below, which is where each of those reasons comes from.awareis the same cross-surface summary returned byPOST /comments.Branch on
decision, not on the presence ofreply: the two nudge decisions both carry text and mean different things downstream.Engage: join someone else's thread
Both motions above begin with the character: it comments, or somebody answers its comment.
POST /comments/engagebegins somewhere else entirely — with a conversation between other people, under a post the character may never have touched. Somebody is wrong about caulk, or asking a question your character can actually answer, and the character drops into their thread.Nobody spoke to us. Everything that makes this endpoint different follows from that one fact.
1. Your connector must send the thread. For a reply we already hold the rows — our comment, who answered it, what they said. Here we have nothing: we never saw these comments, so what you put in
threadis the character's only context. Send the ancestor chain root → target at minimum; the whole visible thread is better, because that is what the character reads when it decides which comment to answer.2. Abstaining is the normal outcome, not an error. Leave
target_comment_refout and the character reads the thread, picks the one comment it would stop on — or decides none of it is its business and says nothing at all. That comes back as a200withreply: nullandreason: "no_target_selected". It is the common case. Branch onreplybeing present, never on the call having succeeded.platformpost_refmax_engagements_per_postcounts against.thread{comment_ref, author_handle, text, depth?, parent_ref?}.posttarget_comment_refmax_chars20–2200characters. See How long a comment can be.When the character does speak, it tells you which comment it is answering:
Post that text as a reply to
target_comment_ref— not as a top-level comment on the post, and not to the post's author. And when it passes:decisionuses the same vocabulary as a reply —comment_reply,skip,bow_out— with one deliberate absence: engage never returnsdrive_to_dm_nudge. Thread warmth seeds from the Person's global baseline and a recorded purchase slams that to 70, so a known buyer would otherwise be told "dm me" on the character's very first public reply to them, under a stranger's post. Nudges belong in the character's own threads.The other
reasonvalues you will see —thread_too_deep,max_engagements_per_post,target_too_short,already_engaged_this_comment,target_is_us,hostile/bot-accusation— come from the engage settings below, and each one is listed in full in the Comments reference.Sending the same thread twice is safe
The character never answers the same comment twice (
already_engaged_this_comment), and it will not join more thanmax_engagements_per_postthreads under one post. So a connector that re-scans a post on every pass does not need its own bookkeeping — re-send what you can see and let the stop rules do the work.How long a comment can be
Every comment, reply and engage reply is held to a length limit. Unless you change it, that is 150 characters on every platform. Until 2026-10-12 the default was set per platform (Instagram 220, X 280, Reddit 500); it is now 150 everywhere.
platforminstagramtiktokx,twitter,x_twitterredditthreadsTo change it, set Max comment length (
max_comment_chars) in the workflow's comment settings, or sendmax_charson a single call to any of the three endpoints. Both take20–2200characters;max_charswins over the setting for that call. Either way the limit never goes above the platform's own ceiling (the last column), somax_chars: 1000on TikTok still gives you 150. The limit is counted in Unicode code points.The writer is told the limit before it writes. It is a ceiling, not a target: comments still come out as long as the thought needs, which is usually well under a high limit (measured on Flash-Lite: a limit of
1200gave 260–410 characters,600gave 235–270). Use it to keep comments shorter, or to let them run longer than the 150-character default; it will not pad a comment to fill it. What comes back carries two fields, on every response that has generated text (comment, or areplyoncomment_replyanddrive_to_dm_nudge) and never on askiporbow_out:max_chars— the limit that was applied: yours, the setting's, or the default of 150.truncated—trueonly when we shortened the text (it ran over the limit and 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 that a longer draft was shortened to isfalse. Over the limit, we cut at the last sentence end inside it when that keeps at least 40% of the limit, otherwise at the last comma, semicolon, colon or dash that keeps 40%, otherwise at the last space, and drop any trailing comma, colon or dash. A cut at a clause ends without a full stop, so it staystruncated: true.A longer limit lets the writer write more, and a longer comment costs more. A
max_charsoutside20–2200is a400before anything is billed. In the TypeScript SDK it ismaxCharsoncomment,commentReplyandcommentEngage, and in the Python SDKmax_charsoncomment,comment_replyandcomment_engage, both from 2.8.0.What the comment settings control
Every
decisionyou get back is the product of the character's comment settings for that platform. They live on the platform's workflow — the Comments editor in the dashboard, next to the DM flow — and they are the reason two identical calls can behave differently on two platforms. You don't send them in a request — the one exception is the length limit, whichmax_charscan override for a single call. They are configuration, and this table is here so an unexpectedskipis diagnosable rather than mysterious.200withignore_reason: "comments_disabled"and generate nothing. See below.full_threadcomment_onceposts the top-level comment and never replies: every/comments/replyreturnsskip·mode=comment_once. The inbound reply is still recorded.skip·reply_to_strangers=off.bow_out·hostile/bot-accusationand marks the threadBOWED_OUT.skip·max_replies_per_thread.skip·max_replies_per_author.skip·max_replies_per_author_total.drive_to_dm_nudgedecision at all.POST /chatcalls for them arrive with history.max_comment_chars)20–2200characters, never above the platform's ceiling. Blank (null) or0keeps the default of 150 characters. Amax_charson the call wins over it. A longer limit costs more per comment.comment_hard_rules)engage_promptalready does, so they hold far better than the same words appended to a comment prompt. Blank leaves the prompts unchanged.digits)banned_openers)["that", "honestly"]. A comment that opens with one is written again once; if the second also does, only that word is dropped.engage_enabled)/comments/engageonly. Off returnsskip·engage_disabled— the other two endpoints keep working. On just means "don't refuse when the connector asks"; the character never joins a thread you didn't send.max_engagements_per_post)skip·max_engagements_per_post. Low on purpose — six replies scattered across six threads under one post reads far more like a bot than six in one conversation.engage_min_target_chars)skip·target_too_short. "this", "lol", "^" and a bare link give the writer nothing to answer, so it falls back to agreeing.engage_max_thread_depth)skip·thread_too_deep. Nobody is reading a 40-deep slapfight. Measured from each comment'sdepthwhen you send it, otherwise by followingparent_ref. Width is not depth: forty top-level comments on one post are depth 1 and are fine.engage_prompt) is its own directive, not the reply one: nobody has spoken to the character, so it must not thank anyone for replying, greet, or refer to a comment of its own. Changes what it writes, never whether it writes.A
skiporbow_outis not an error and not a wasted charge: every stop rule above is a deterministic check that runs before any model call, so the character decided to stay quiet without generating anything. Engage's abstention (no_target_selected) is the one that does reach a model — the small temperature-0 call that picks a comment — and it is not billed either: the charge fires only when areplycomes back.Writing a persona that comments well
Three things the settings table does not say, each learned from running dozens of personas at once:
self_factsfirst, then her constant lorebook entries, and keeps the first 6 (posts keep 12). A guard fact added as the 8th line ("never claims to be a nurse") is never seen. Put guards and the facts that matter most at the front ofself_facts. Over MCP,create_characterandupdate_characterreturn afacts_notenaming exactly the facts beyond the cut.style_noteorpersonalitysayssounds like "love this, so true", expect "love this, so true" under many posts. Describe the tone ("short, warm, one reaction, no question") instead of quoting lines. This applies tocomment_prompt,reply_prompt,drive_prompt,engage_prompt,style_noteand the character'spersonalityandself_facts.bind_platformwithtemplate_engine_id) clones the template'scomments.comment_prompt,reply_prompt,drive_prompt,engage_prompt,comments.style_noteandposts.style_notetoo, so a new character inherits another character's subjects. The response lists them ascopied_persona_text; passblank_persona_text: trueto start those fields blank (the built-in defaults then apply) on the new workflow. The source workflow is never touched.Cross-surface awareness
This is the headline of the Characters API: one coherent persona everywhere. A lead is identified by
(platform, handle), and the same lead who comments on a post and who slides into the DMs resolves to a single Person. The character remembers across surfaces — DMs ↔ comments — and across multiple posts and threads.recognized_lead(onPOST /comments) istruewhen the post'sauthor_handleis already a known Person.aware(on both endpoints) is a compact summary of what the character already knows about that Person — earlier DMs, prior comments, other threads — so a public reply lands consistently with whatever was said in private, and vice-versa.You don't wire any of this up. As long as you pass the real
author_handle/replier_handle, awareness is resolved for you. For the full model, see Core concepts.Driving a comment into a DM
When a thread is warming up,
POST /comments/replymay returndecision: "drive_to_dm_nudge". Post thereplytext as usual — it gently steers the replier toward the DMs. When that person then messages the character, feed the inbound DM toPOST /chatwith the same(platform, handle). Because it's the same Person, the character picks the conversation up with full context from the public thread — no handoff state to manage on your side.When comments are switched off
Comments are configured per character and per platform, and they are ON by default — a character with a bound workflow you have never opened the Comments settings on will comment. The setting exists so you can opt a platform out.
When someone has done that, every call you make here returns a
200success envelope whosedatais a no-op marker rather than a comment.If your comment calls seem to do nothing, this is the first thing to check.
This is not an error — there is no
comment/replyfield, so simply post nothing. It lets you call the comment endpoints unconditionally from your connector and let the dashboard decide whether the character speaks publicly on that platform.Branch on
ignore_reason, not on theexplainprose. To check the flag before you write any code, callGET /self-test— it reportscomments_enabledper binding.Errors you may hit here
error.code400invalid_requestpost_reforreplier_handle, invalid JSON, or amax_charsoutside20–2200. Nothing is billed.401invalid_token402insufficient_tokens403forbidden404not_found422validation_errorimage_urlsnot a list of strings).429rate_limitedRetry-Afterheader.500internal_errorNote that comments not configured is a
200no-op (above), not a404. The SDKs raise typed errors —PaymentRequiredErrorfor402,NotFoundErrorfor404,ValidationErrorfor422, and so on. See errors.md for the full error model and rate-limits.md for per-token limits.Related
POST /chat; the other side of adrive_to_dm_nudge.402works.