Characters — feature spec & system design¶
Status: SHIPPED (live-verified 2026-06-02) · Issue: #145 · Branch:
feature/character-creationLiving document. Evolve this as the feature and Flow's API evolve. Raw capture provenance lives inCHARACTER_RECON.md; the cross-feature decision lens is the REST-path capability matrix. Credit: pattern-references @kittinan's avatar PR #123 (attach-flow idiom + CLI scaffolding shape) — but built greenfield offdevelop, NOT stacked on #123 (that branch is CONFLICTING and likeness-specific; see Design decisions).Shipped scope:
gflow character create+list+show+voices+rm. Name-based reuse via@Nameprompt mentions is shipped (v0.40.0, #344) for bothimageandvideopaths — see REFERENCE_STRATEGIES. An explicit--character <id>flag ongflow video(a separate, repeatable-ref delivery mechanism) remains not yet implemented — see Backlog.Status (2026-06-02, LIVE-VERIFIED): the create saga + character-editor UI generation are shipped and verified end-to-end on my-profile (face + front/side/back triptych body, both reference slots bound, images downloaded, character read back). This document's selectors, editor URL, generation protocol (Option B),
flow/entitiesPATCH, and the CLI surface are VERIFIED against shipped code, not guesses. Thenanopromodel picker is wired best-effort (non-fatal) and pending an explicit live confirmation — see Backlog.
1. What it is¶
A Character is a reusable, project-scoped identity in Google Flow: a named subject with one or more reference images (e.g. face + full-body), an optional voice, and an optional personality. Once a character exists, it can be reused across generations in the same project so the same subject appears consistently from shot to shot. This is the feature requested in #145.
gflow exposes this as a first-class command group, gflow character, plus a --character reuse flag on
generation commands.
1.1 How it works (plain language)¶
gflow character create builds a character in two credited generation steps, kept visually consistent by
seeding the second step with the first:
- Face (slot 0). gflow submits your
--face-promptand Flow generates the face image. This becomes the character's first reference image. - Body (slot 1). gflow activates the editor's Create-Body mode, which auto-attaches the just-generated
face image as the reference seed. gflow then submits its own self-contained
front/side/back triptych prompt (it wraps your
--body-promptbody/outfit description). Because the face is attached as the seed, a single credited generation produces all three body angles — front, side, and back — that match the face.
The character editor is a two-mode surface ("Portrait" / "Create Body"). Current Flow reuses one Slate
editor across both modes, so gflow anchors Create Body by the locale-independent accessibility_new icon
beside the portrait-image button and requires a new generated-face reference chip to mount before using the
shared box. Older cohorts that mount a second prompt box remain supported through a Slate-count-rise gate. In
both cases gflow verifies the selected
body box still owns focus before clearing or typing. If the structural mode signal or focus check fails,
the body step aborts before any autosaved prompt edit; post-type isolation failures abort before the credited
submit. This guards the race that corrupted a stored portrait prompt live on 2026-07-25/0.43.0. Re-run
gflow character create to retry; the saga reuses the entity and completed face slot (§4).
The result: two generations (face + body-triptych), the body kept on-model by the face seed. Everything else — naming the character, setting the voice/personality, and saving — is free metadata (REST), not generation. See §4 for the full saga and §6.2 for the wire payload.
Why this, not Avatar (#123)¶
Flow's Avatar/likeness (referenceLikenesses) is verified-identity + region gated —
GET /v1/flow/likeness:checkEligibility returns {"ineligibilityReasons":["REGION"]} for our accounts, so
it is held. Characters are broadly available and are the consistency primitive most users actually need.
2. Domain model¶
Project
└── Entity (entityType = CHARACTER) # the Character; id = entityId, lives at /project/{projectId}/character/{entityId}
├── displayName # "Denidra"
├── characterInfo
│ ├── personalityNotes # free text; guides behaviour when prompt is silent
│ ├── audioReferences[] { presetVoiceId } # voice, e.g. "Charon" (canonical = Capitalized UI name; see §7)
│ └── imageReferences[] { workflowId } # face, body, … → each Workflow has a primaryMediaId
└── thumbnailMediaId # the cover image
A Character references Workflows, not raw media. Each reference image is the primary media of a
generation Workflow (workflow.metadata.primaryMediaId), and the Workflow is bound to the Character via
workflow.parentEntityId. This indirection lets a reference image be re-rolled without breaking the link.
3. Capability & cost¶
Per the REST-path capability matrix, the feature is a hybrid: only the two generative calls require the reCAPTCHA browser path and consume credits; all structural operations are free REST.
| Operation | Endpoint | Transport | reCAPTCHA | Credits |
|---|---|---|---|---|
| Mint character | POST /fx/api/trpc/flow.createEntity |
tRPC (session) | no | no |
| Generate reference image | POST /v1/projects/{projectId}/flowMedia:batchGenerateImages |
Flow UI (passive-capture) — direct POST is 403-walled | yes | yes |
| Set primary image | PATCH /v1/flowWorkflows/{workflowId} |
Bearer REST | no | no |
| Save character | PATCH /v1/flow/entities |
Bearer REST | no | no |
| List / show | GET /fx/api/trpc/flow.projectInitialData |
tRPC (session) | no | no |
| Reuse in video | POST /v1/video:batchAsyncGenerateVideoReferenceImages |
Flow UI (passive-capture) — reCAPTCHA-gated | yes | yes |
| Poll reuse status | POST /v1/video:batchCheckAsyncVideoGenerationStatus |
Bearer REST | no | no |
| (optional) Archetype prompt | POST /fx/api/trpc/flow.generateCharacterPrompt |
tRPC (session) | no | no |
| (context) Eligibility | GET /v1/flow/likeness:checkEligibility |
Bearer REST | no | no |
Auth model per [[aisandbox-rest-needs-sapisidhash]]: aisandbox REST wants Authorization: Bearer ya29…
(from GET /fx/api/auth/session); tRPC calls ride the labs.google session cookies.
4. Sequence — create a character (VERIFIED, Phase-2)¶
The shipped saga lives in services/character_create.py::character_create and is persist-before-spend +
crash-recoverable: the recorder writes a STARTED row (carrying entityId) before any credited
generation, records each slot as it completes (record_character_partial), and only flips to SUCCEEDED
after the entity is saved. On a crash anywhere after createEntity, the next run detects the incomplete row
(find_incomplete_character), reuses the entityId, and skips any slot whose ids are already recorded — no
orphan double-charge. Generation rides Flow's own page JS in the character editor (Option B,
passive-capture); the structural calls are direct REST.
sequenceDiagram
participant CLI as gflow character create
participant SAGA as character_create saga
participant REC as recorder (SQLite)
participant BFF as labs.google (tRPC)
participant API as aisandbox-pa (Bearer REST)
participant UI as Flow character editor (UI passive-capture, reCAPTCHA)
CLI->>SAGA: create(project, name, face_prompt, [body_prompt, voice, personality])
SAGA->>BFF: client.create_entity(projectId) %% free REST
BFF-->>SAGA: entityId (entityType=CHARACTER)
SAGA->>REC: record_character_started(entityId) %% persist-before-spend gate
SAGA->>UI: generate_character_image(slot 0=face) %% credit; navigates /fx/{locale}/…/character/{entityId}
UI-->>SAGA: media[mediaId], workflows[workflowId, parentEntityId]
Note over SAGA: GUARD — reject if workflows[0].parentEntityId != entityId (WireFormatError)
SAGA->>API: commit_workflow(wf0, primaryMediaId=m0) %% PATCH flowWorkflows — free REST
SAGA->>REC: record_character_partial(face slot)
opt body_prompt given
SAGA->>UI: generate_character_image(slot 1=body; imageInputs REFERENCE=face) %% credit, SEQUENTIAL
UI-->>SAGA: media, workflow (parentEntityId guard re-checked)
SAGA->>API: commit_workflow(wf1, primaryMediaId=m1)
SAGA->>REC: record_character_partial(body slot)
end
SAGA->>API: patch_entity {displayName, characterInfo{personalityNotes, audioReferences, imageReferences}} %% free REST
API-->>SAGA: persisted entity
SAGA->>REC: record_character_completed(SUCCEEDED)
SAGA-->>CLI: CharacterCreateResult {entityId, workflowIds, voice, personality}
VERIFIED 2026-06-02:
workflows[0].parentEntityId == entityIdis the binding invariant —client.generate_character_imageraisesWireFormatErrorif the captured workflow'sparentEntityIddoes not equal the requestedentityId(guards both the spike-v1 wrong-project 404 and a foreign workflow). Face and body are sequential, nevergather'd (a true data dependency: body references the face mediaId).
5. Sequence — reuse a character (R2V)¶
sequenceDiagram
participant CLI as gflow
participant GEN as Flow gen (reCAPTCHA)
participant API as aisandbox-pa
CLI->>GEN: video:batchAsyncGenerateVideoReferenceImages\n requests[].referenceEntities:[{entityId}]
GEN-->>CLI: { workflows[], media[], remainingCredits }
loop until done
CLI->>API: video:batchCheckAsyncVideoGenerationStatus
API-->>CLI: status / mediaId
end
6. Payloads (I/O)¶
6.1 flow.createEntity¶
- In
{ "json": { "projectId": "<uuid>" } } - Out
{ result.data.json: { projectId, entityId, entityInfo:{ entityType:"CHARACTER", displayName:"Untitled Character", characterInfo:{} }, createTime, updateTime } }
6.2 flowMedia:batchGenerateImages (character reference image)¶
- In (per request item):
wrapped with
{ "imageModelName": "NARWHAL", "imageAspectRatio": "IMAGE_ASPECT_RATIO_LANDSCAPE", "structuredPrompt": { "parts": [ { "text": "<prompt>" } ] }, "seed": 826730, "imageInputs": [ { "imageInputType": "IMAGE_INPUT_TYPE_BASE_IMAGE", "name": "<mediaId>" }, { "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE", "name": "<mediaId>" } ] }clientContext{ recaptchaContext{token}, projectId, tool:"PINHOLE", sessionId }whereprojectIdis the character's existing project (NOT a new one), and:plus"mediaGenerationContext": { "batchId": "<uuid>", "entityContext": { "entityId": "<characterId>", "characterSlot": { "imageReferenceIndex": 0 } } }useNewMedia:true.entityContextis what binds the generation to the character — the server then stamps the new workflow'sparentEntityIdand auto-links it into the entity'simageReferences[slot].Wire-vs-CLI caveat:
imageModelName("NARWHAL") andimageAspectRatioare Flow's internalbatchGenerateImagesendpoint fields (recon), NOT the gflow CLI surface —gflow character createexposes only--model nano2|nanoproand has no aspect-ratio control.
imageReferenceIndex: 0 = face, 1 = body, etc. imageInputs: empty for a fresh slot; REFERENCE
(= the face mediaId) when generating the body; BASE_IMAGE+REFERENCE when refining an existing slot
(refinements also carry clientContext.workflowId to reuse that slot's workflow).
- Out (VERIFIED 2026-06-02 — flowMedia:batchGenerateImages returns HTTP 200, no 403, when driven
through Flow's own JS in the character editor):
{ "media": [ { "name": "<mediaId>", "workflowId": "<workflowId>",
"image": { "generatedImage": { "seed": 826730, "mediaGenerationId": "<id>", "prompt": "<text>",
"modelNameType": "NARWHAL", "fifeUrl": "<signed>" }, "dimensions": {} } } ],
"workflows": [ { "name": "<workflowId>",
"metadata": { "displayName": "<name>", "primaryMediaId": "<mediaId>", "batchId": "<uuid>",
"createTime": "…", "updateTime": "…" },
"projectId": "<projectId>", "parentEntityId": "<entityId>" } ] }
workflows[0].parentEntityId == entityId is the binding invariant — client.generate_character_image
raises WireFormatError otherwise. The signed fifeUrl is never persisted (the client returns only ids
— mediaId/workflowId — to the saga; structural by construction).
⚠️ This request must be issued by Flow's own page JS — not by us. Two spikes (2026-06-02) fixed the bounds: (1) gflow's
generate_imageclicks "new project" + omitsentityContext→ workflow lands in the wrong project (flowWorkflowsPATCH 404'd); (2) a self-assembled direct POST with our own minted reCAPTCHA token → HTTP 403 (the reCAPTCHA wall — [[rest-path-capability-matrix]]; body shape matched the real HAR, only the token differed). Decision (§11): drive generation through the character editor UI (existing project → Personagens → new character → generate) so Flow's JS setsentityContext+ passes the WAF, and gflow passive-capturesmedia[]/workflows[]. The structural ops below stay direct-REST.
6.3 PATCH flowWorkflows/{workflowId}¶
- In
{ "workflow": { "name", "projectId", "metadata": { "primaryMediaId" } }, "updateMask": "metadata.primaryMediaId" }
6.4 PATCH flow/entities (save) — VERIFIED 2026-06-02¶
- In (LIVE-VERIFIED request shape;
updateMaskis a comma-joined path list){ "entity": { "projectId", "entityId", "entityInfo": { "displayName": "Denidra", "characterInfo": { "personalityNotes": "…", "audioReferences": [ { "presetVoiceId": "gacrux" } ], "imageReferences": [ { "workflowId": "…" } ] } } }, "updateMask": "entityInfo.displayName,entityInfo.characterInfo.personalityNotes,entityInfo.characterInfo.audioReferences,entityInfo.characterInfo.imageReferences" } - Out (VERIFIED) the persisted
entity, echoing{ entity: { projectId, entityId, entityInfo: { entityType:"CHARACTER", displayName, characterInfo }, createTime, updateTime } }.
6.5 flow.projectInitialData (list/show)¶
- Out
result.data.json.projectContents.entities[], each:{ projectId, entityId, entityInfo{ entityType:"CHARACTER", displayName, characterInfo{ imageReferences, audioReferences, personalityNotes } }, thumbnailMediaId, thumbnailDimensions, createTime, updateTime }
6.6 video:batchAsyncGenerateVideoReferenceImages (reuse)¶
- In (per request item):
wrapped with
{ "aspectRatio": "VIDEO_ASPECT_RATIO_PORTRAIT", "textInput": { "structuredPrompt": { "parts": [ { "text": "<prompt>" } ] } }, "videoModelKey": "abra_r2v_10s", "seed": 3952, "metadata": {}, "referenceImages": [ { "mediaId", "imageUsageType": "IMAGE_USAGE_TYPE_ASSET" } ], "referenceEntities": [ { "entityId" } ] }mediaGenerationContext{ batchId, audioFailurePreference:"BLOCK_SILENCED_VIDEOS" },clientContext{ projectId, tool:"PINHOLE", userPaygateTier, sessionId, recaptchaContext{token} },useV2ModelConfig:true.referenceEntitiesis a list → multiple characters per generation (VIDEO_MODEL_CAPABILITY_MULTI_REFERENCE). - Out
{ remainingCredits, workflows[], media[] }; resolve final asset by pollingvideo:batchCheckAsyncVideoGenerationStatus.
7. Voices¶
A preset library of 29 Gemini-TTS voices is offered in the UI (api/character.py::VOICES).
gflow character voices [--json] lists them — each with a short descriptor and a preview sample URL
following the pattern https://gstatic.com/aitestkitchen/voices/samples/{Name}.wav.
The canonical voice id is the Capitalized UI display name (e.g. Charon, Sulafat, Gacrux) — the same
token that drives the sample URL, so a canonical id round-trips to a playable sample.
gflow character create --voice <Name> validates the value case-insensitively (so charon and Charon
both normalize to the canonical Charon) and sets audioReferences[].presetVoiceId via the entity PATCH.
Wire-case caveat (open): a prior live run sent a lowercase id (
"charon") and Flow persisted it, but whether Flow applies the voice from a lowercase id vs the Capitalized canonical form is UNVERIFIED. gflow adopts the Capitalized form as canonical per the UI. The voice list is currently a hardcoded constant; fetching the live list from Flow's voice API and confirming thepresetVoiceIdwire-case are tracked in the Backlog. A "create new voice" flow ("Criar nova voz") exists in the UI but is not yet implemented.
8. CLI surface (shipped v0.12.0)¶
The create / list / show / voices commands below are shipped and
live-verified in v0.12.0 (the user-facing reference lives in
USAGE § gflow character). Name-based reuse via
@Name prompt mentions is shipped (v0.40.0, #344) on both the image and
video paths. An explicit --character <id> flag on gflow video
remains backlog (row marked not yet implemented). gflow character rm
is shipped (see the table below).
| Command | Maps to |
|---|---|
gflow character create --project <id> --name <name> --face-prompt … [--body-prompt …] [--voice <Name>] [--personality …] [--model nano2\|nanopro] [--profile …] [--locale en-US] [--json] (SHIPPED v0.12.0 — shipped option names; characters have NO aspect-ratio control) |
createEntity → gen face (slot 0) → commit_workflow → optional gen body (slot 1) → commit_workflow → patch_entity, via the persist-before-spend saga (§4). --body-prompt is a body/outfit DESCRIPTION — gflow wraps it in a self-contained front/side/back triptych instruction and seeds it with the generated face (auto-attached as the body-mode reference), so one body generation yields all three angles. Generated images are downloaded to storage; the result reports each slot's local path. |
gflow character list --project <id> [--json] (SHIPPED v0.12.0) |
projectInitialData → entities[CHARACTER] |
gflow character show --project <id> (--id <entityId> \| --name <displayName>) [--json] (SHIPPED v0.12.0) |
projectInitialData (single); name collision → exit 11 with disambiguation hint |
gflow character voices [--json] (SHIPPED v0.12.0) |
list the 29 preset voices (name / description / sample-url); language-agnostic discovery, mirrors gflow models --json |
gflow video … --character <id> (repeatable → multi-ref, explicit flag) |
not yet implemented (Phase 3) — referenceEntities on video:batchAsyncGenerateVideoReferenceImages |
gflow character rm (--id/--name) |
Shipped v0.13.0 (#150) — POST flow:batchDeleteAssets (Bearer; FREE — no reCAPTCHA/credit) |
gflow image … --character <id> (explicit flag) |
id-based reuse shipped as gflow image t2i/i2i --reference-entity <id> (wire captured 2026-06-08, api/image.py) |
@Name in a t2i/i2i/video prompt |
name-based mention resolution shipped v0.40.0 (#344) — resolves to the staged character entity, works on both image and video paths; see REFERENCE_STRATEGIES |
All commands take --project <id> (required) — every endpoint (createEntity, projectInitialData,
reuse) is project-scoped; mirrors gflow scene / gflow data ergonomics. Use --id/--name flags, never a
positional <id|name>. Exit codes are reused (not-found → 11 ConfigurationError; reuse poll-timeout →
9 TransportTimeoutError, with the entityId surfaced in the hint so the paid image isn't re-spent).
Model aliases resolve via the existing picker (nano-banana-2 → NARWHAL, omni-flash/r2v → abra_r2v_10s);
never hard-code wire names in user-facing help — see [[cli-model-aliases-verify-in-docs]].
9. Design constraints (first-class)¶
- Language-agnostic. No localized UI text in any code path. The structural calls are REST (language independent by construction). The single browser-bound step (image/video generation) reuses gflow's existing reCAPTCHA transport and must select via Material-Symbol ligatures / structural roles, never localized strings — Flow renders in the Chrome profile language ([[flow-locale-leak-icon-ligatures]]).
- Real-browser auth. Generation requires a Chrome-strategy profile ([[real-browser-auth-mandatory]]).
- Credit safety. Only steps in §3 marked Credits = yes spend; persist-before-spend (record
entityId+ workflow rows before the credited gen) and verify per the verification ledger. Structural REST ops are free and idempotent-ish (PATCH with updateMask). - Privacy / redaction (VERIFIED).
personalityNotesis free-text PII → routed through the recorder'sprompt_fields(..., mode=prompt_mode)so redacted mode (GFLOW_CLI_HISTORY_PROMPTS=redacted) hashes it exactly like a prompt (data/recorder.py+data/redaction.py). The signedfifeUrlis never persisted —client.generate_character_imagereturns only ids (mediaId/workflowId) to the saga, so nosignature=/Expires=URL can reach a DB row (structural guarantee, not just a strip). Reuse_post_json's redaction wrapper on the new tRPC/REST calls (show_locals=False). - Data layer. Persist
entityId,workflowIds,primaryMediaIds, voice, personality so a character is recoverable and reusable across sessions (mirrors the scene persistence + migration pattern). - Docs are first-class. This document is the living spec; update it in the same PR as any behavioural
change. Indexed in
docs/INDEX.md.
10. Open items¶
See §14 Backlog — not yet implemented for the consolidated list. In
short: an explicit --character <id> flag on gflow video reuse (Phase 3; @Name prompt mentions
already ship this on both image and video, see #344), live voice
listing + presetVoiceId wire-case confirmation, custom-voice creation, nanopro picker live confirmation,
and extra body angles. The optional creation-agent (flowCreationAgent/sessions,
flow.generateCharacterPrompt archetypes) is not required for the scripted path; consider an --archetype
convenience later.
11. Design decisions (post-predict)¶
/gflow:predict verdict CAUTION (6/10) — feasible, no STOP; mitigations folded in here.
- Greenfield, not stacked on PR #123. #123 is CONFLICTING and likeness-specific (
use_avatar,referenceLikenesses, Avatar-tab clicks). Retargeting it costs more than building fresh offdevelop. We pattern-reference its attach idiom + CLI shape and credit @kittinan, but do not branch from it. - Own DTOs + saga in a service layer. Add
api/character.pyfrozen DTOs (mirroringapi/scene.py) and a dedicated character-image request DTO — do not overloadGenerateImageRequestwith an entity flag. The 4-step create saga (createEntity → gen → PATCH workflow → PATCH entity) lives in a service function that takes the client as a dependency, not incli_character.py(testable without a browser; pre-stages the hexagonal handler layer). - Reuse existing poll infra. The reuse path is async (submit + poll
batchCheckAsyncVideoGenerationStatus) but the in-treeconcatenate_scene/_poll_video_statusloop +CHECK_VIDEO_STATUSroute already implement this. Factor a shared_poll_until(...)helper rather than a second poll loop. This is not blocked on the pending fire-and-forget design. - Transport split: structural ops (
flow.createEntity,projectInitialDatavia tRPC session;flowWorkflows/flow/entitiesPATCH via Bearer REST) are Page-safe (each_post_json/_patch_jsonchecks a Page in/out per round-trip — no deadlock). Generation/reuse rides the existing reCAPTCHA browser transport (costs credits). face→body gen is a true data dependency → sequential, nevergather'd. - Data layer: migration adds
OperationKind.CHARACTER; persistentityId,workflowIds,primaryMediaIds, voice, personality. Model shallowly (raw ids) — the schema is reverse-engineered from one capture and may drift. - Validation gate: before production code, a
scripts/dev/spike runs the full create→persist loop on my-profile (≈1 credit) and asserts the character read-back carriesimageReferences[workflowId]. - Spike result (2026-06-02, 1 credit):
createEntity✅ live. Generation step disproved thegenerate_imagereuse assumption — it created a new project and theflowWorkflowsPATCH 404'd. Root cause + fix captured in §6.2 (entityContext+ existing-project gen). Orphans from the run: empty entityfbad10fb…in project96e81f06…+ throwaway project108069e2…(1 image) — delete when convenient. - ~~Transport decision = Option A (direct REST gen)~~ — SUPERSEDED by Option B below. Kept for provenance only; do NOT implement (the 403 spike disproved it — the
generate_character_image/_build_character_batch_bodydirect-POST design in this bullet is dead). gflow already owns every primitive decoupled from new-project navigation:_mint_recaptcha_token(project-agnostic — mintsgrecaptcha.enterprise.executeon any loaded Flow page),_post_json(aisandbox Bearer + 401-refresh),routes.batch_generate_images_url(existing_projectId);experimental/bearer.pyalready proves the directbatchGenerateImagesPOST returnsmedia[]/workflows[].generate_imageis unusable because it passive-captures Flow's own JS request (can't injectentityContext) and force-clicks "+ New project". → Build: api/character.py: frozenCharacterImageRequestDTO +_build_character_batch_body(...)extending the image body with per-requestseed,imageInputs(BASE/REFERENCE), optionalclientContext.workflowId(refine), andmediaGenerationContext.entityContext={entityId, characterSlot:{imageReferenceIndex:N}}+useNewMedia:true. Do not overloadimage.py::_build_batch_generate_images_body.client.generate_character_image(*, project_id, entity_id, req, image_reference_index, recaptcha_action="imageGeneration")→ mint token, POST via_post_json, parseworkflows[].{name,parentEntityId}+media[].name; thencommit_workflow(primary) +_patch_jsonflow/entities(name/voice/personality).- Risk to validate: a self-assembled POST with our own minted token may score differently on
reCAPTCHA/WAF than Flow's native JS call → the corrected 1-credit spike asserts
workflows[0].parentEntityId == entityIdANDprojectId == existing(not new) AND read-backimageReferences[0].workflowId. - ❌ Option A REJECTED — generation is reCAPTCHA-walled (spike v2, 2026-06-02, 0 credits). The
self-assembled
batchGenerateImagesPOST returned HTTP 403 (createEntity ✅, reCAPTCHA mint ✅). Our request body shape matched the real successful HAR exactly; the only difference is the reCAPTCHA token — aTokenMintertoken scores too low / wrong-context vs Flow's native page-JS token. This confirms [[rest-path-capability-matrix]]: generation is never browser-free; Bearer fixed the 401, not the reCAPTCHA wall. No credit spent (403 before generation). A retry won't help — the token can't be made WAF-valid outside Flow's own JS. - ✅ Decision = Option B (UI-driven generation; structural ops stay REST). Generation (character image
create AND video reuse) must ride Flow's native UI so Flow's JS issues the WAF-valid request, which
gflow passive-captures — identical mechanism to the existing
generate_image/generate_video. Revised transport map: - Structural (direct REST):
flow.createEntity✅,PATCH flowWorkflows✅,PATCH flow/entities✅,projectInitialData✅ (all proven / non-generative — no reCAPTCHA). - Generative (UI automation, passive-capture): character image gen must be driven in the character
editor of the existing project (navigate to project → Personagens → new character → set prompt →
generate) so Flow's JS sets
entityContextand passes the WAF; gflow capturesmedia[]/workflows[]from the response. Reuse gflow's prompt-submit + response-capture machinery (ui_automation.py), but in the character-editor context (NOT the new-project flow). Video--characterreuse = resource picker (search resources → Personagens → include-in-prompt context-menu action; captions are localized, selectors locale-free since issue #170) → generate; mirrors #123_attach_likeness. All selectors ligature/structural only (language-agnostic). - Cost reality: the feature is UI-automation-heavy (new character-editor + picker selectors), not the "mostly REST" earlier framing. Structural metadata is REST; the value-generating steps are UI.
12. UI-automation selectors (Option B) — VERIFIED 2026-06-02¶
Language-agnostic (ligature/structural) selectors. Flow renders in the profile locale, so never match on
localized text — use Material-Symbol ligatures (i.google-symbols:text('<lig>')) or structure. Rows marked
VERIFIED are confirmed against the shipped api/transports/ui_automation.py + spike DOM dumps (my-profile).
Editor URL (VERIFIED — /fx/ prefix required). After REST createEntity, the character-editor path is
Built by routes.character_editor_url(locale, projectId, entityId). The /fx/ prefix is load-bearing —
without it Flow 404s (caught by spike T-A; the fix sourced the URL from LABS_FX_BASE). Navigating to this
URL (not the new-project flow) is what makes Flow's JS set entityContext on the generation. Name / voice /
personality are REST PATCH flow/entities. The only new generation selectors are the prompt/submit (already
owned by gflow) and the body-mode activation/settle signals.
| Purpose | Selector | Conf. |
|---|---|---|
| Editor-ready anchor | the prompt textbox becoming visible: div[role=textbox][data-slate-editor='true'] (_CHARACTER_EDITOR_READY_SELECTOR) |
VERIFIED |
| Prompt box (char editor) | div[role=textbox][data-slate-editor='true'] (== PROMPT_INPUT_SELECTORS[0]) |
VERIFIED |
| Generate / submit | arrow_forward google-symbols button: button:has(i.google-symbols:text('arrow_forward')) (== SUBMIT_BUTTON_SELECTORS) |
VERIFIED |
| Name field | input[placeholder] — localized placeholder; do NOT rely on the text (structural: it is the editor's lone placeholdered input) |
VERIFIED |
| Personality field | the panel textarea (localized placeholder) — prefer REST PATCH |
VERIFIED |
| Slot 0 image (face) | aria-label "Imagem do personagem 1" — localized, note only (do not load-bear on the text) |
med (localized) |
| Body mode (slot 1) | Current cohort: portrait-image sibling button:has(img) + button:has(i.google-symbols:text-is('accessibility_new')); settle signal: a newly mounted generated-media card with an img and exact cancel icon. Legacy cohort fallback: icon-only [role=button] carrying exact add_2, followed by Slate-box count rise. No localized button text or positional index. |
VERIFIED live 2026-07-26 |
| Model picker (char editor) | button[aria-haspopup='menu']:has(i.google-symbols:text-is('arrow_drop_down')) (normal editor uses crop_16_9) |
VERIFIED |
| Voice picker | voice_selection google-symbols ligature |
VERIFIED (ligature) |
| Personagens nav (project) | button:has(i.google-symbols:text-is('accessibility_new')) (cards are div[role=button]; tag disambiguates) |
high |
| Delete character (rm, v2) | button:has(i.google-symbols:text-is('delete')) |
high |
| Picker tab → Personagens | [role=dialog] button[role=tab]:has(i.google-symbols:text-is('accessibility_new')) |
high |
| Picker "include in command" | primary button in the dialog footer — no ligature → structural anchor (last/primary dialog button); only appears after an option is selected |
low ⚠️ |
⚠️ Documented risk (Task-12 e2e): the picker include button (no ligature, localized text only) remains a
positional/structural anchor to confirm under a live non-EN locale. Body mode no longer uses the stale
.nth(1) selector; its mode and settle icons are locale-independent. Slot 0's "Imagem do personagem N"
aria-label is localized — used as a note, never as a load-bearing selector.
Shared chrome already automated by gflow (reuse as-is): add_2 (+/Criar add-media → opens picker),
arrow_forward (submit), the Slate prompt box, model dropdown, check/Concluir.
13. Definition of Done — full e2e scenario coverage¶
gflow character is not done until a full e2e suite covers every scenario below. Unit tests (TDD,
mocked transports) and the spike are necessary but not sufficient — live e2e is the only thing that has
caught synthetic-fixture bugs here (the scene read-back {} bug survived 753 unit tests). Built on the e2e
cost-stratification framework (markers + GFLOW_CLI_E2E_RUN_* opt-ins; credit-spending tests parameterized).
The /gflow:plan exit criteria must list each scenario as a named, covered test.
| Area | Scenarios that must be covered |
|---|---|
| create | face-only; face→body (chained imageInputs, sequential); --voice; --personality; all options; fresh-entity per run; persist-before-spend recovery (fail after createEntity / after gen / after workflow PATCH → recoverable, no orphan credit loss); invalid --project (404→exit 11); invalid --voice (exit 11 with valid set); invalid --model (Click Choice nano2/nanopro → exit 2; characters have no --aspect); expired session (exit 8 AuthExpired) |
| list | empty project; N characters; --json; non-character entities excluded |
| show | by --id; by --name; name collision → exit 11 + disambiguation; not-found → exit 11; --json |
| voices | voices; voices --json; --voice validated against this set (language-agnostic, no localized strings) |
video --character |
single character; multiple --character (multi-ref); character + --ref asset image; async submit→poll success; poll timeout → exit 9 with entityId in hint (so the paid asset isn't re-spent); invalid character id → exit 11 |
| data layer | persist entityId/workflowIds/primaryMediaId/voice/personality; recovery after crash; redaction: personalityNotes redacted under GFLOW_CLI_HISTORY_PROMPTS=redacted; no signed fifeUrl/signature= in any DB row |
| cross-platform / i18n | non-EN Flow locale (ligature/structural selectors only); accented --personality/--face-prompt round-trip (PYTHONUTF8); Windows/macOS/Linux paths |
| observability | each saga step emits a structlog event; entityId surfaced on every failure for recovery |
Live-tier e2e (credit-spending: create, video reuse) gated behind explicit opt-in env and run on my-profile per the verification ledger (file count + dims + structlog invariants + read-back), not just mocked transports.
Phase-2 coverage status (2026-06-02)¶
Now covered by shipped unit/BDD tests (see docs/superpowers/character-scenario.md for the full scenario
table → test mapping):
-
1 — char-gen never direct-POSTs:
tests/services/test_character_gen_no_direct_post.py¶ -
3/#4 — persist-before-spend recovery: saga recovery tests + BDD resume¶
(tests/services/test_character_create_saga.py,tests/data/test_find_incomplete_character.py,tests/features/test_character_create_steps.py) -
5 —
(parentEntityIdbinding guard:test_generate_character_image_rejects_foreign_workflow¶tests/api/test_client_generate_character.py) + saga binding test -
15/#16 — redaction (personality hashed, no signed URL):
tests/services/test_character_create_redaction.py¶ - recorder tests (
tests/data/test_recorder_character.py) -
18 — accented
(--personality/--face-promptround-trip: CLI accented test¶tests/cli/test_cli_character_create.py) -
21 — transport-None guard (
Chrome-strategy guard atgenerate_character_imageraises whentransport is None); the real headed¶__aenter__is deferred to live e2e (Task 12) -
22 — face→body sequential (never gathered): saga sequential test¶
Remaining LIVE-e2e-only (Task 12): the live binding/recovery/UTF-8 e2e on my-profile — real
parentEntityId == entityId against Flow's wire, mid-saga crash recovery against the real DB, and the
body-mode icon/reference settle signals + picker-include structural selectors under a non-EN locale.
13.1 Known issues¶
Editor shows "Untitled Character": gflow sets the character name via
entityInfo.displayName(REST PATCH), which is correct in the API and ingflow character show/list. Flow's character editor view has a separate title widget that gflow does not populate, so the editor may cosmetically display "Untitled Character". Display-only — the entity name is correct everywhere else.
14. Backlog — not yet implemented¶
The following are explicitly out of the shipped scope and tracked for future work:
- Voice listing via the live API. The 29-voice catalog is currently a hardcoded constant
(
api/character.py::VOICES). A future feature should fetch the live voice list from Flow's voice API (endpoint TBD) so the catalog cannot drift from what Flow actually offers. As part of this, confirm the wire-case ofpresetVoiceId— a prior live run sent lowercase"charon"and Flow persisted it, but the canonical UI form is Capitalized"Charon"; it is unverified which form Flow actually applies the voice from. - Voice creation ("Criar nova voz" in the UI). A future
gflow character voice create-style command to mint a custom voice rather than only selecting a preset. - Model-picker live confirmation.
--model nanoprois wired best-effort / non-fatal (a picker miss warns and continues rather than failing); an explicit live run is needed to confirmnanoprois actually selected in the character editor. gflow video --character <id>(explicit flag, Phase 3). Reuse a character in a video generation via an explicit repeatable--character <id>flag, as areferenceEntitiesalternative to@Nameprompt mentions (which already work on the video path, shipped v0.40.0/#344). Wire protocol is captured (§5, §6.6) but the flag-based command is not yet built.- Extra body angles beyond the built-in triptych. The shipped body slot generates a single front/side/back triptych. Generating additional angles/poses (e.g. re-feeding the front view as a reference seed for a new slot) is a future enhancement.
- ~~
gflow image --character(name-based).~~ Shipped v0.40.0 (#344) as@Nameprompt mentions (services/mentions.py), on both image and video paths for character references. The image-path wire shape (gflow image t2i/i2i --reference-entity <id>) shipped earlier (live capture 2026-06-08,api/image.py; asserted by_assert_image_entities_attached). See REFERENCE_STRATEGIES for the decision guide across all three mechanisms. (gflow character rmis now shipped, #150.) - Set the Flow editor title field so the editor isn't "Untitled Character" (see §13.1 Known issues).
Investigate either the UI title box (with a Ctrl+A merge-bug workaround) or finding the dedicated title
field/endpoint that the editor widget reads from, distinct from
entityInfo.displayName. - Saga code-quality refactor (non-blocking; Sonar duplication/complexity). Extract a
_run_slothelper for the duplicated face/body blocks in the create saga, and return a small DTO fromgenerate_character_imageinstead of a 3-tuple cast.