Skip to content

API Reference — Posts ​

Generate an original post for a community — a subreddit, a group, a feed. Unlike a comment, a post answers to nobody: there is no lead, no handle and no thread. You report what the community currently looks like; the API decides whether there is a good reason to post, and if there is, returns the fields to submit. Your connector submits them — FluidTalk generates the post and 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/postsDecide whether to post, and generate the post if so
POST/posts/mediaStore an image to post and get a URL for asset.url

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.

Read the guide first

This page is the field-by-field contract. Posts explains the part that decides how you use it — that a refusal is the normal answer, and which refusals you should act on.

Set a 60-second timeout

One call runs the whole pipeline: understand the community, score candidate angles, gate them against the persona, write, review and audit. A normal call takes about 15–17 seconds at the median and some take over 30. Anything shorter than 60 will cut off work you are about to be billed for anyway.


POST /posts ​

Request body ​

NameTypeRequiredDescription
platformstringYesThe platform the community is on, e.g. "reddit". Selects the bound workflow.
communityobjectYesThe community object — who this room is and what it forbids.
postsobjectNoThe posts object — what is currently on the front page.
top_commentsarrayNoThe top_comments array — where the angle actually comes from.
formatobjectNoThe format object — the fields this platform accepts and their limits. Defaults to title + body + optional flair.
assetobjectNoThe asset object — an image to post: the url from POST /posts/media, or any image URL the model provider can fetch.
modestringNo"live" (default) or "mock". See Testing.
reuse_analysisbooleanNoReuse the shared read of this community instead of reading it again; your character's ideas are still its own. Ignored when you send an asset or a brief. See Reusing the analysis.
always_draftbooleanNoGet drafts even when we would not post. decision is unchanged; a refusal also carries drafts — up to three draft objects, best first — or draft: null and draft_unavailable. Costs more and takes longer than a plain refusal. See Always get a draft.
briefstringNoA topic to steer this one call toward. The analysis looks for the angle on it this community actually wants, or reports honestly that it has no appetite for it.

community plus at least one of posts or top_comments is the practical minimum. With neither, there is nothing to read the room from — but the call still runs and is billed, so do not send one. (Earlier versions of this page said such a call was free; it never was.)

The community object ​

FieldTypeRequiredDescription
refstringYesYour id for the community, e.g. "r/running". Used to group post history and to score novelty against what this character has already posted here.
namestringNoDisplay name.
descriptionstringNoThe community's own description of itself.
rulesstring[]NoThe community's rules, verbatim and in order. Scored against, then audited against the finished post. Rules are cited back by their position in this list.
flairsstring[]NoThe flairs this community allows. A returned flair is always one of these strings or null — an invented flair is dropped, not submitted.

The posts object ​

What is on the community's front page right now. Every key is optional; each holds an array of post-like objects.

FieldTypeDescription
hotobject[]Currently hot.
risingobject[]Gaining traction.
topobject[]All-time or windowed top.
recentobject[]Newest.

Each entry may carry title, body, score, num_comments, created_at and flair. Only title matters; the rest sharpen the read of what this room rewards. These are used to profile the community and to score novelty — an angle that repeats something already on the front page fails min_novelty.

The top_comments array ​

FieldTypeDescription
post_titlestringThe post the comments sit under.
commentsobject[]Objects with a text field. score is used if present.

This is the highest-value field in the request. The angle is drawn from questions people asked and nobody answered, which live in comments and not in titles. Without this array the character can only respond to your front page, and tends to produce a competent restatement of it.

The format object ​

The platform's post contract. This is how the endpoint stays platform-agnostic — nothing about post shape is hardcoded.

FieldTypeDescription
fieldsobject[]Each has key (string), required (boolean) and optional max_chars (integer).
notesstringFree text passed to the writer, e.g. "text post; no links".

Default: title (required, 300), body (required, 4000), flair (optional). Length limits are enforced deterministically after generation. A required field that comes back empty is named in assembly_notes, and on a draft it is flagged missing_fields.

The asset object ​

FieldTypeRequiredDescription
urlstringYesWhere the image is: the url returned by POST /posts/media, or any image URL the model provider can fetch. A blank url is treated as no asset.

When present, a vision model describes the image before anything else runs, and every later stage works from that description — never from the URL. The description is then checked in one short call against the community's description, rules and post titles, before the full read of the community, so a misplaced image is refused at the asset gate after one image description and that one check. Drafts that ignore the image are rejected at asset-presence.

An image the model cannot see is a hard stop: decision: "generation_failed", with a reason that starts the image could not be seen (…) and a vision object saying why — not a caption written blind. It is recorded like any other generation_failed, but when the provider could not fetch the image, retrying the same URL will not help: send the bytes to POST /posts/media and use the URL it returns. vision.reason says which case you are in.

The vision object ​

Present whenever you sent an asset, on every decision except posts_disabled and paused — including a generation_failed from the internal deadline. Mock mode returns a representative one whenever you send an asset.

FieldTypeDescription
suppliedintegerAlways 1 — the image you sent.
seenbooleanWhether the model could see the image. false means nothing was written, and the decision is generation_failed.
descriptionstring | nullWhat the model saw — the text every stage worked from. null when seen is false.
reasonstring | nullWhy it could not see it, when seen is false.
reasonMeaning
vision_failedThe vision call failed — which is what a URL the model provider cannot fetch produces. Rehost the bytes through POST /posts/media.
unreadable_imageThe model found nothing to describe: a blank, corrupt or single-colour image. Send a different image.
empty_descriptionThe model returned an empty description. Retry is reasonable.
timeoutThe internal deadline ran out while the image was still being described. Retry is reasonable; if it keeps happening, rehost the image through POST /posts/media — a slow image host is the usual cause.

Response ​

FieldTypeDescription
decisionstringOne of the decision values. Branch on this first.
postobjectPresent only when decision is post. Holds the fields named by your format — by default title, body, flair.
gatestringPresent on no_suitable_post_found: which check declined. See gates.
reasonstringProse explanation of a refusal, for the operator.
anglestringWhy this was worth posting.
evidencestringThe community text that showed the demand — usually the unanswered comment.
scoresobjectThe six axes for the chosen candidate: relevance, interest, novelty, discussion_potential, rule_compat, context_fit.
reviewobjectReviewer verdict: approved, ai_tells, nearest_duplicate.
rule_auditobjectPer-rule verdicts on the finished post.
candidatesarrayEvery idea considered, with its scores and persona verdict — see the candidate object. Present on refusals — this is what tells you why the room was declined.
thresholdsobjectThe floors the ideas were judged against: min_relevance, min_novelty, min_rule_compat. Present whenever the ideas were scored.
assembly_notesstring[]What the deterministic pass changed: links stripped, an invented flair dropped, a field clamped.
draftsobject[]Only when you sent always_draft, only on no_suitable_post_found: up to three draft objects, best first — own voice before outsider, then the fewest problems. Absent when none was possible.
draftobject | nullThe first of drafts. null when none was possible.
draft_unavailableobjectPresent when draft is null: code (one of the published codes) and reason (prose).
analysisobjectPresent when you sent reuse_analysis, on every decision except posts_disabled and paused. See the analysis object.
visionobjectPresent when you sent an asset, on every decision except posts_disabled and paused: whether the model saw the image, and what it saw. See the vision object.

The candidate object ​

FieldTypeDescription
anglestringThe idea.
unanswered_intereststringThe demand behind it.
evidence_quotestringThe community text it came from.
scoresobjectThe six axes, 0–100.
passes_floorsbooleanWhether it cleared all three floors in thresholds.
failed_floorsstring[]The floors it missed: any of relevance, novelty, rule_compat.
can_serveboolean | nullThe persona check: can this character write it without inventing a life? null when the check did not judge it — never treated as yes.
would_have_to_inventstring[]What the character would have to invent to write it.
must_not_claimstring[]What a post on it must not claim about the character.
honest_way_instring | nullA way the character could approach it honestly, when the check found one. Usually null.
persona_reasonstring | nullThe persona check's own sentence.
serves_assetbooleanWhen you sent an asset: whether the image can genuinely support the idea.
verdictstringThe scorer's one-line verdict.

The draft object ​

Up to three are returned in drafts with always_draft on a refusal. The decision is still ours; the choice is yours. Every draft has passed a check for invented biography — anything it claims about the character's own life that is not established — or it is not returned. See Always get a draft for what each value means.

FieldTypeDescription
sourcestringWhere the draft came from: rejected_draft, servable_idea, persona_ideas or outsider.
flagsstring[]What we would have held it back for. Empty means we found nothing against this draft itself — it came from the fallback, after the refusal had already been decided.
postobjectThe fields to submit, shaped by your format, like post on a post decision.
angle, evidence, scoresAs on a post decision, for this draft's idea.
passes_floors, failed_floors, can_serve, would_have_to_invent, must_not_claim, persona_reasonAs on the candidate object, for this draft's idea.
reviewobject | nullThe reviewer's verdict on this draft. null when its answer could not be read.
rule_auditobject | nullPer-rule verdicts on this draft. null when you sent no rules or turned the audit off.
asset_presenceobject | nullWhen you sent an asset: which of the things in the image the draft mentions.
assembly_notesstring[]What the deterministic pass changed.

The analysis object ​

FieldTypeDescription
reusedbooleantrue when this call used a read of the community made earlier (by any caller); false when this call made the read.
idstring | nullThe read's id. Calls that share a read share this. null when this call's read was not kept for others — its payload had no post titles to vouch for it with — or when no read was made.
age_secondsintegerHow old the read was when this call used it.
title_overlapnumberShare of the titles you sent that were in the payload the read was made from, 0–1.
refreshed_becausestring | nullWhen this call made a new read although an older one existed: why the older one could not be used.
signalsintegerWhat the read found the community asking for.
signals_in_your_payloadintegerHow many of those are evidenced in the payload you sent. Only these can be offered to you.
signals_openintegerHow many of those no recent post has already been drawn from. Your character's ideas come from these.
skippedstringPresent, with reused: false and none of the other fields, when the call could not reuse at all (an asset or a brief was sent).
json
{
  "data": {
    "decision": "post",
    "post": {
      "title": "Running multiple days in a row: what actually helped",
      "body": "…",
      "flair": "Training"
    },
    "angle": "managing consecutive running days without accumulating niggles",
    "evidence": "Now if I run multiple days in a row I usually have some type of hurt ankle",
    "scores": { "relevance": 92, "interest": 88, "novelty": 85,
                "discussion_potential": 90, "rule_compat": 100, "context_fit": 90 },
    "review": { "approved": true, "ai_tells": [], "nearest_duplicate": null },
    "assembly_notes": []
  },
  "request_id": "req_8f3c2a1b"
}

A refusal is a 200. no_suitable_post_found is the expected answer for a quiet community, not an error. It is billed, because finding out cost real model work — most of what a post costs, or about half as much on a reused analysis. Treat generation_failed differently: that one is ours, and retrying is reasonable.

Switched off is a safe no-op. Posting is ON by default, so an integration that has never touched the setting gets real posts (and is billed for them). If someone has opted this workflow out, the call returns 200 with decision: "posts_disabled" — nothing generated, nothing billed. GET /self-test reports posts_enabled per binding.

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 decision: "paused". Nothing is generated and nothing is billed; the character resumes automatically once the plan is upgraded. See Paused characters.

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

Example ​

bash
curl -X POST https://api-talk.fluidvip.com/api/v1/characters/posts \
  -H "X-Connector-Token: ftc_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "reddit",
    "community": {
      "ref": "r/running",
      "name": "running",
      "rules": ["No medical advice.", "No promotion."],
      "flairs": ["Discussion", "Training", "Race Report"]
    },
    "posts": {
      "hot": [{ "title": "Weekly mileage check-in", "score": 412, "num_comments": 88 }]
    },
    "top_comments": [
      { "post_title": "Weekly mileage check-in",
        "comments": [{ "text": "If I run multiple days in a row I always end up with a hurt ankle" }] }
    ]
  }'
python
from fluidtalk import FluidTalk

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

res = ft.post(
    platform="reddit",
    community={"ref": "r/running", "rules": ["No medical advice."],
               "flairs": ["Discussion", "Training"]},
    posts={"hot": [{"title": "Weekly mileage check-in", "score": 412}]},
    top_comments=[{"post_title": "Weekly mileage check-in",
                   "comments": [{"text": "If I run multiple days in a row I get a hurt ankle"}]}],
)

if res.decision == "post":
    submit_to_reddit(res.post.title, res.post.body, res.post.flair)
elif res.decision == "generation_failed":
    retry_later()        # ours, not the community's — safe to try again
else:
    log_refusal(res.decision, res.gate, res.reason)
typescript
import { FluidTalk } from "fluidtalk";

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

const res = await ft.post({
  platform: "reddit",
  community: { ref: "r/running", rules: ["No medical advice."], flairs: ["Discussion", "Training"] },
  posts: { hot: [{ title: "Weekly mileage check-in", score: 412 }] },
  topComments: [{ post_title: "Weekly mileage check-in",
                  comments: [{ text: "If I run multiple days in a row I get a hurt ankle" }] }],
});

if (res.decision === "post") {
  await submitToReddit(res.post!.title, res.post!.body, res.post!.flair);
} else if (res.decision === "generation_failed") {
  retryLater();          // ours, not the community's
} else {
  logRefusal(res.decision, res.gate, res.reason);
}

Both SDKs default this call to a 60-second timeout rather than their usual one, because a post runs the whole pipeline in a single request. Requires fluidtalk 2.3.0 or later (pip install -U fluidtalk / npm i fluidtalk@latest); earlier versions have no post() and you must call the route directly. The reuse_analysis / reuseAnalysis argument needs 2.4.0.

Status codes ​

StatuscodeWhen
200—A post, or a refusal. Read decision.
400invalid_requestMalformed body — e.g. missing platform or community.ref.
401invalid_tokenMissing, invalid, or revoked connector token.
402payment_requiredThe owner's wallet can't cover this generation — no model call was made. Top up. See Billing.
422validation_errorA field failed validation (e.g. community.rules isn't a list).
429rate_limitedRate limited; honor the Retry-After header.
500internal_errorUnexpected server error.

Note that an internal breakage during generation is not a 500 — it is a 200 with decision: "generation_failed", because the call itself succeeded and you need the request_id and the billing outcome that come with it.


POST /posts/media ​

Upload the image a character is about to post 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 all you have is the bytes (a screenshot, a camera roll), when the image's own host refuses the model provider (generation_failed with vision.reason: "vision_failed"), or unconditionally if you'd rather not depend on a third-party host at all. The returned URL goes in asset.url on POST /posts.

Not the same as POST /inbound-media or POST /comments/media. /inbound-media is for a lead-sent DM photo and files into the character's "<name> — Inbound" folder; /comments/media is for the image on someone else's post the character is commenting on, and files into "<name> — Comments". An image the character posts itself is neither, so it gets its own "<name> — Posts" folder in the character owner's storage.

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 upload is not idempotent: each call stores a new file and returns a new URL.

Request body ​

NameTypeRequiredDescription
platformstringYesThe platform the community is on, e.g. "reddit".
community_refstringYesThe community the image will be posted to — the same value you send as community.ref on /posts.
data_b64stringYesThe image bytes, base64-encoded. Max 10 MB decoded. Must be a still JPEG, PNG, WebP or GIF: the model reads a post's image by URL and cannot read video or HEIC, so those are refused here rather than stored for a /posts call that could only fail. Convert first.
filenamestringNoOriginal filename; used for the stored file's name (only the name — any path is dropped).
content_typestringNoIgnored in favour of the bytes: we sniff the real type, and the bytes win.
modestringNo"live" (default) or "mock". Mock requires community_ref to start with test_, exactly as /posts does on community.ref. See Testing.

Response ​

FieldTypeDescription
okbooleanAlways true on a 200.
urlstring | nullThe permanent URL to put in asset.url. null on the two no-op branches below.
file_idstringThe stored file's id in the owner's workspace.
community_refstringEchoed back, so a batched caller can match responses to communities.
ignored / ignore_reasonstring"posts not configured" / "posts_disabled" — posting is switched off for this workflow. Nothing was stored.
paused / pause_reasonboolean / stringtrue / "plan_limit" — the character is over the plan's cap. Nothing was stored. Checked before posts_disabled.
explainstringPlain-English reason, on either no-op branch.

Treat a null url as "post nothing"

Both no-op branches return HTTP 200, and /posts would not write anything for that character either. Branch on url being present, not on the status code.

Examples ​

bash
curl -X POST https://api-talk.fluidvip.com/api/v1/characters/posts/media \
  -H "X-Connector-Token: ftc_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "reddit",
    "community_ref": "r/running",
    "data_b64": "iVBORw0KGgoAAAANSUhEUg...",
    "filename": "shoes.png",
    "content_type": "image/png"
  }'

The SDKs take the raw bytes and base64 them for you (ft.post_media / ft.postMedia, fluidtalk 2.5.0 or later), then you pass the url to ft.post as asset_url / assetUrl.

python
from fluidtalk import FluidTalk

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

up = ft.post_media(platform="reddit", community_ref="r/running",
                   data=raw_bytes, filename="shoes.png")

if up.url:                                       # None: posting is off, or the character is paused
    res = ft.post(platform="reddit", community={"ref": "r/running", "rules": ["No promotion."]},
                  top_comments=top_comments, asset_url=up.url)
    if res.decision == "generation_failed" and res.vision and not res.vision.seen:
        log_unseen_image(res.vision.reason)      # the image, not the community
typescript
import { FluidTalk } from "fluidtalk";

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

const up = await ft.postMedia({
  platform: "reddit", communityRef: "r/running", data: rawBytes, filename: "shoes.png",
});

if (up.url) {                                    // null: posting is off, or the character is paused
  const res = await ft.post({
    platform: "reddit",
    community: { ref: "r/running", rules: ["No promotion."] },
    topComments,
    assetUrl: up.url,
  });
}

Status codes ​

StatuscodeWhen
200—Stored (url set), or a safe no-op with url: null (posting off, or plan-paused).
400invalid_requestA blank platform or community_ref, a data_b64 that is empty or not valid base64, bytes that are not a JPEG, PNG, WebP or GIF still (the message names what they are), or — in mode: "mock" — a community_ref that does not start with test_.
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.
422validation_errorA required field is missing — e.g. no community_ref.
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 post_media_storage before you send any bytes.

See also ​

FluidTalk Characters API — part of the Fluidvip ecosystem.