Skip to content

Character mention tokens

Users can @mention their own characters inside a generation prompt via the faceted composer: a tiptap editor (apps/brisket/src/features/create-generate/components/faceted-composer/mention-prompt-editor.tsx) wired into the four faceted generate pages (carousel, photo, media, video) behind the character-mentions PostHog flag, with Generate blocked on an invalid, too-many, or malformed mention. This decision covers the server-side plumbing the composer depends on. A workflow execution needs to know which characters a mention refers to, in addition to the primary character it already receives as inputs.character_id. The encoding has to survive as plain prompt text — it is stored in custom_text / video_prompt and other string inputs alongside the words the user typed — while staying unambiguous to parse and safe against injection into the workflow’s own template grammar.

A mention is written inline in prompt text as <@character:UUID> (apps/sirloin/internal/app/services/media/mentions/mentions.go). Sirloin is the sole authority: GenerateMedia scans custom_text, video_prompt, and every other string / string-array value in generation_inputs_json.inputs for tokens, validates each mentioned character (same-account ownership, readiness, and NSFW capability), and rejects a malformed token — all before any media row is created or a credit is charged. The workflow declares how many characters it accepts as maxItems on character_ids, counted with the primary character, defaulting to 2 when absent; a workflow opts in to mentions at all by annotating its prompt input with x-ui: "mention-editor" in input_schema. Validated ids are persisted at the top-level characterIds key of generation_inputs_json, ordered [primary, ...mentions], de-duplicated. The worker injects them into the Brain execution as inputs.character_ids, falling back to [character_id] for media created before this change or where no ids were persisted.

Brain and Flank treat character_ids as a system input on every MEDIA_GENERATION workflow: Brain adds it to a workflow’s input_schema at publish time if the author’s contract omits it (and rejects a declaration with the wrong type), and Flank’s structured inputs editor derives and declares it the same as character_id / media_id (deriveInputAnnotations and ensureMediaGenerationInputs in apps/flank/app/lib/workflow-io.ts) — an ordinary editable annotation an author can edit or remove, not a system-owned lockout. What actually keeps it out of brisket’s generation UI is sirloin reserving the character_ids key in its contract layer (apps/sirloin/internal/app/services/mediacontract/mediacontract.go). The prompt text itself is never rewritten — the <@character:UUID> tokens stay in custom_text / video_prompt exactly as typed, and character_ids travels alongside them as a separate, structured input.

A workflow opts a prompt input into the mention editor with x-ui: "mention-editor" in input_schema. x-ui is deliberately a general slot for per-input UI hints, not a mentions-specific keyword — the roadmap adds more hints beyond this one, and a single shared keyword avoids a new bespoke boolean each time. The only thing that validates execution inputs against input_schema is Brain’s z.fromJSONSchema conversion (apps/brain/src/modules/domain/workflow/services/contract-input.validation.ts), which ignores unknown keywords such as x-ui — and fails open entirely on a schema it cannot convert — so this is additive; the property only needed handling in Flank, whose structured inputs editor now preserves unrecognised per-property keywords through a save instead of silently dropping them, which is what lets x-ui survive a round trip through the form editor.

The token deliberately does not reuse {{...}}, the workflow template placeholder syntax (see JEXL expression dialect in workflow templates).

Checking mentions only at generation time is not enough on its own: a finished multi-character image can become the input of a later generation (Gallery Edit, image-to-video, Kack Me, an edit of an edit) that mentions nobody. So every media.media row also records lineage_collaborating_character_ids (migration 169_media_lineage_collaborating_character_ids.sql): each character whose likeness may be in that media. Sirloin computes it in generationLineage (apps/sirloin/internal/app/services/media/lineage.go) as the validated [primary, ...mentions] plus the lineage of every input media — the source_media_id row and every generation_input_media row, including a kack_me gallery reference owned by another character. User uploads (INTERNAL_INPUT) add nothing, because they are user photos, not generated likenesses. Rows written before the column hold NULL, and lineageCollaboratingCharacterIDs reads them as {character_id}.

On an NSFW request, checkLineageNsfw runs every inherited character — those not already validated as primary or mention — through the same characterAllowsNsfw rule. Readiness is not required, since the character is not generated, only already in the picture. A missing character fails closed. The refusal is FailedPrecondition “source media features a character that does not support NSFW generation”, returned before any media row or credit. Capability is read on every request, so granting a character 18+ later unlocks reuse of media that shows it.

This list is separate from characterIds on purpose. characterIds is what the workflow receives as inputs.character_ids. The lineage only gates the rating: it never reaches the workflow and does not count toward maxItems. The Media proto exposes it as media_lineage_collaborating_character_ids, and brisket’s faceted surface uses lineageBlocksNsfw (apps/brisket/src/lib/lineage-nsfw.ts) to make 18+ variants unavailable before submit. That is only a UI hint; sirloin decides.

  • {{uuid|character}}. Shares the template grammar, so user-authored prompt text could be crafted to look like a template placeholder or transform call — a template-injection surface into node config resolution. Brain only resolves templates in node config, not in user text, so this would also require teaching prompt fields a syntax they have no other reason to understand.
  • Plain @Name with a side list of ids. Fragile and ambiguous: names are not unique, can contain characters that collide with prompt punctuation, and matching text back to an id after the fact (on edit, on redisplay) has no reliable inverse.
  • @handle. Handles are not guaranteed to exist, are not guaranteed unique, and can change after a mention is written, which would silently invalidate stored prompts.
  • Keeping the lineage inside generation_inputs_json. That key is only written for workflow-backed generations, while an edit through the legacy path must carry the lineage too. It would also mix rating state into the workflow’s input payload.
  • Sending inherited characters to the workflow in character_ids. That would change what workflows do on a plain edit, and maxItems would start refusing edits of images that already show several characters.
  • Stored prompts (custom_text, video_prompt) contain raw <@character:UUID> tokens. Any surface that displays a stored prompt outside the faceted composer (gallery, media details, “use this prompt again”) needs a @Name renderer to avoid showing the token literally; the faceted composer itself already renders tokens as chips when reopened. Building that renderer is a separate, follow-up ticket.
  • JEXL transforms to work with the token inside a workflow (mention_ids, replace_mentions), forwarding character_ids into subworkflows, and copying it into Generation.inputVariables are deferred follow-ups, not part of this decision.
  • Because character_ids is sirloin-injected and always overrides a client-supplied value, no workflow author or API caller can spoof which characters a sirloin-dispatched generation is attributed to. This does not hold for Brain’s own execute endpoints (apps/brain/src/modules/domain/api/controllers/workflow.http.controller.ts, apps/brain/src/modules/domain/workflow/controllers/execution.http.controller.ts), which accept caller-supplied inputs verbatim, or for a Flank editor test run, where withSystemRunInputs (apps/flank/app/lib/server-fns/executions.ts) only fills a default character_ids when the caller did not supply one.
  • generation_input_media is loaded, bound to the character and added to the lineage even when the request also sets source_media_id. Only the choice of SourceID is skipped then (generationInputSourceID in apps/sirloin/internal/app/services/media/generatemedia.go).
  • The reuse gate has one known gap. A raw top-level sourceMedia in generation_inputs_json, sent without generation_input_media, is not validated when the request arrives, so it adds no lineage. The worker still resolves it. Reaching this needs a gRPC request crafted and sent straight to sirloin: brisket nests user inputs under inputs, and foxy360 does not set generation_inputs_json. Closing it means rejecting raw sourceMedia outright. That is a follow-up to FOXY-1362.
  • Re-uploading a downloaded multi-character image as a user upload carries no lineage, the same as any user photo.