$ cat workspace-template.yaml

B

Brand Voice

// Derive a source-grounded writing-style profile from a person's real posts, essays, docs, and site copy - choosing whichever sources they have - then draft on-voice content for any brief and channel without generic AI tropes.

Composition
#voice#brand#writing#style#content

// Canvas Preview

canvas.flow

// Instruction

instruction.md

Brand Voice

Overview

This workspace derives a reusable VOICE PROFILE from a person's real writing - pulled from whichever sources they actually have (Drive documents, specific URLs, or a web search for their public posts) - then drafts on-voice content for a given brief and channel. It collects everything it needs in a SINGLE setup prompt, then runs to completion without prompting again. The far-left Sources section holds one node per source type. They start disconnected from Make Array on purpose: from that one prompt you connect ONLY the sources the user provides into Make Array. A source left unconnected is never executed, so an unconfigured source can never block the run.

Workflow Chain

[Sources]  (section, far left - start DISCONNECTED from Make Array)  [Drive Document Reader]  [Web Search] -> [Fetch URL as Markdown (from search)]  [Fetch URL as Markdown (manual)]        |        |  (Phase 1: connect ONLY the provided source(s) into a Make Array item port)        v   [Make Array] -> [Extract Voice Profile (Text Summarizer)]                        -> [Text Merge (+ brief)] -> [Write On-Voice] -> [Save File]

Node Reference

  • Sources (section) - visual group holding the source nodes. None are wired to Make Array yet; you connect the provided ones in Phase 1 via upsertEdge.
  • Drive Document Reader - reads writing samples from a Drive folder. Configure driveItemId via selectDriveItem. Output port: content.
  • Web Search - finds URLs of the author's public writing. Configure query (e.g. "{{author}} blog posts essays"), num_results. Output urls already feeds its Fetch node.
  • Fetch URL as Markdown (from search) - fetches the searched URLs. Input urls from Web Search (pre-wired). Output port: markdown.
  • Fetch URL as Markdown (manual) - fetches author-supplied URLs (X threads, landing page). Configure urls (Text[]) via upsertNode. Output port: markdown.
  • Make Array - collects the connected source texts into one ordered array. Item ports item_0..item_9 (none required). Output: items -> Extract Voice Profile content.
  • Extract Voice Profile (Text Summarizer) - recursively reads all connected source material and extracts the VOICE PROFILE. Configure: prompt (baked; substitute {{author}}/{{goal}}), llmModel. Output: summary = the VOICE PROFILE.
  • Text Merge - joins profile + brief. Input text1 from the profile. Configure text2 = the brief ({{brief}}). Output: text.
  • Write On-Voice (Text Transform) - drafts the content. Input input_text from Text Merge. Configure prompt (baked; substitute {{channel}}), llmModel. Output: output_text = the draft.
  • Save File - saves the draft to Drive. Configure destinationFolder via selectDriveItem. Output: driveItemId.

Execution Strategy

You MUST complete all 4 phases in order: Phase 1 -> Phase 2 -> Phase 3 -> Phase 4. Do NOT skip any phase. There is exactly ONE user prompt in the whole workflow (the Phase-1 bundle); never call requestUserDecision again after it.

Phase 1: Collect everything in ONE prompt, then set up the canvas

  1. Call requestUserDecision EXACTLY ONCE, bundling all of these as separate questions in the single pause (this is the only decision prompt in the entire workflow):
    • key sources (allowMultiple): "Which source material can you provide?" options: Drive folder of my writing, Specific URLs (X threads, essays, landing page), Web search of my public posts. The free-text box captures the URLs and/or the search topic.
    • key voice: "Whose writing is this, and what will you use the voice for?" (free text -> author + goal)
    • key brief: "What should I write in this voice?" (free text)
    • key channel: "Which channel?" options: X, LinkedIn, Email, Generic.
  2. From the sources answer, for EACH chosen source: configure it and upsertEdge its text output into the next free Make Array item port (item_0, then item_1, ...). Drive -> selectDriveItem + upsertNode driveItemId, then upsertEdge content -> Make Array item_0. Manual URLs -> upsertNode urls (from the free text), then upsertEdge markdown. Web search -> upsertNode query (from the free text; Web Search already feeds its Fetch node), then upsertEdge the Fetch (from search) markdown. The Drive picker (selectDriveItem) is unavoidable for Drive and is NOT a second decision prompt. Connect at least one source; never touch a source the user did not pick.
  3. Apply the rest of the answers in ONE batchUpsertNodes call: substitute {{author}}/{{goal}} (from voice) into "Extract Voice Profile" prompt; set "Text Merge" text2 to brief; substitute {{channel}} into "Write On-Voice" prompt.

Phase 1 complete. Now proceed to Phase 2. Do NOT prompt the user again.

Phase 2: Extract the voice profile

  1. nodeExecutor "Extract Voice Profile" - it auto-resolves Make Array and every connected source upstream.
  2. getNodeOutput and show the VOICE PROFILE to the user. Do NOT poll in a loop.

Phase 2 complete. Now proceed to Phase 3.

Phase 3: Write the on-voice content

  1. nodeExecutor "Write On-Voice" - it auto-resolves Text Merge (profile + brief) upstream.
  2. getNodeOutput and show the draft to the user.

Phase 3 complete. Now proceed to Phase 4.

Phase 4: Verify and finish (no new prompt)

  1. verification - present the final audit (profile + draft) to the user.
  2. followUp - summarize, and OFFER (do NOT auto-run, and do NOT open a new decision prompt) to: save the draft to Drive (run "Save File" with a selectDriveItem destination only if the user then asks), draft for another channel, or add more sources and re-extract.

This is the front-loaded pattern: the only user prompt is the single Phase-1 bundle. Saving is offered in followUp rather than auto-executed, so no separate confirmation gate is needed and followUp directly after verification is correct. On an edit turn, infer intent from prior state (e.g. previously saved -> refresh) without prompting.

Configuration Tips

User saysConfigure
"Here's my Drive folder of posts"Configure Drive Document Reader; upsertEdge its content -> Make Array item_0
"Pull my public writing from the web"Set Web Search query={{author}}+topic; upsertEdge Fetch (from search) markdown -> Make Array
"Here are some links"Set "Fetch URL as Markdown (manual)" urls; upsertEdge its markdown -> Make Array
"Use my LinkedIn voice"Write On-Voice: substitute {{channel}}=LinkedIn
"My DMs are the real voice"Phase 1 split: clone the source->Extract chain onto the private material
"Use the bigger model"Extract / Write: llmModel=gpt-oss-120b
"Just give me the profile"Run Phase 1-2 only; skip Write and Save

Error Handling

  • Empty or garbled VOICE PROFILE -> no source is connected, or a connected source produced no text; confirm at least one source is connected into Make Array, configured, and reachable, then re-run Extract.
  • A source node was left disconnected by design - that is correct; only connected sources execute. Never connect a source the user did not provide (an empty Drive reader / empty URLs would fail and block the run).
  • Web Search rate_limited=true or empty urls -> fall back to a Drive folder or manual URLs.
  • Fetch failed_count high -> some URLs were unreachable/SSRF-blocked; supply different URLs; the array still carries the successful entries.
  • Draft contains a Hard-Ban phrase -> re-run Write On-Voice; the banned list is in its prompt, the model occasionally slips.
  • "destinationFolder required" on Save -> the selectDriveItem step in Phase 4 was skipped; pick a folder then re-run.

// Dependencies

requirements.py
1from input import DriveDocumentReader2from input import WebSearch3from process import FetchURLAsMarkdown4from process import MakeArray5from process import TextSummarizer6from process import TextMerge7from process import TextTransform8from output import SaveFile

// Variables

variables.yaml
1author:2  type: undefined3  label: "Author"4  description: "Whose voice to capture (name or handle)."5  required: undefined67goal:8  type: undefined9  label: "Goal"10  description: "What the voice will be used for, e.g. 'X launch posts'."11  required: undefined1213brief:14  type: undefined15  label: "Brief"16  description: "What to write in the captured voice."17  required: undefined1819channel:20  type: undefined21  label: "Channel"22  description: "Target channel: X, LinkedIn, Email, or Generic."23  required: undefined

$ git log --oneline

v1.0.3
HEAD
2026-06-12
v1.0.22026-06-12
v1.0.12026-06-12
v1.0.02026-06-12