Character mention tokens
Context
Section titled “Context”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.
Decision
Section titled “Decision”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).
Reusing generated media
Section titled “Reusing generated media”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.
Rejected alternatives
Section titled “Rejected alternatives”{{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
@Namewith 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, andmaxItemswould start refusing edits of images that already show several characters.
Consequences
Section titled “Consequences”- 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@Namerenderer 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), forwardingcharacter_idsinto subworkflows, and copying it intoGeneration.inputVariablesare deferred follow-ups, not part of this decision. - Because
character_idsis 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-suppliedinputsverbatim, or for a Flank editor test run, wherewithSystemRunInputs(apps/flank/app/lib/server-fns/executions.ts) only fills a defaultcharacter_idswhen the caller did not supply one. generation_input_mediais loaded, bound to the character and added to the lineage even when the request also setssource_media_id. Only the choice ofSourceIDis skipped then (generationInputSourceIDinapps/sirloin/internal/app/services/media/generatemedia.go).- The reuse gate has one known gap. A raw top-level
sourceMediaingeneration_inputs_json, sent withoutgeneration_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 underinputs, and foxy360 does not setgeneration_inputs_json. Closing it means rejecting rawsourceMediaoutright. 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.