Imported from eliasstravik/gtm-skills (
skills/gtm-workflow/SKILL.md). Install upstream withnpx skills add eliasstravik/gtm-skills --skill gtm-workflow. Copyright stays with the author.
GTM Workflow
Trigger
Apply this skill when a request concerns a saved workflow: creating, changing, running, scheduling, deploying, upgrading, inspecting, opening its UI, or deleting one, or its table, runs, or diagram.
When creating or changing a workflow that enriches connections or followers and their current companies, load gtm-workflow-enrich-network when installed for that recipe; this skill still owns its lifecycle. Ordinary runs of an unchanged workflow need only this skill.
Show the intent menu only when the user's entire request is /gtm-workflow or /gtm-workflows, with no action or other words. Ask Choose a workflow action. with three choices: Open GTM Workflows, Create a workflow, Manage a workflow, and wait. Manage asks which workflow and action: change, run/test, schedule/deploy, upgrade/repair, or delete. Bare entry performs no creation, execution or deployment.
Open GTM Workflows is already an explicit Open request. Resolve the verified viewer URL and return it immediately using entry links; do not show the intent menu or ask the user to choose Open again. Loading this skill does not turn an explicit request into a bare invocation. Other explicit actions also go directly to their procedure. If Open finds no workspace, explain workspace setup; if the workspace has no workflows, show its empty list.
Scope
This skill owns <workspace>/workflows/, a Vercel Workflow runtime on Nitro copied from templates/ on first use: workflow files, db/tables/, migrations, vercel.json, .env, the local database, runs, diagram pages, and the deployed copy. Runs execute locally against this workspace's own Postgres in data/pg by default; they target the deployed copy when the user asks or the host states there are no local runs, and hosted results are read through the deployed copy's query route. Local Connections uses a trusted component outside workflows/; deployed Connections runs inside the existing private Workflows project. For setup, key discovery, connection changes and component upgrades, read Connections. The remaining workspace files outside workflows/ belong to the other gtm skills.
Inputs
The request; the workspace found by the contract; the ICP or persona whose criteria the workflow copies; references/local.md for commands, keys, and verified facts; references/deploy.md for hosting.
Roles
The user's request authorizes routine code changes and real runs within the configured row and cost caps. State the estimate and proceed; confirmation is reserved for destructive actions under the shared interaction rules. The user connects the workflow project through shared standalone setup or optional gtm-agent setup and chooses the AI backend once per workspace in .env; the agent writes, runs, and reports. Deploy is the push.
Procedure
When persisting enriched people or companies, follow shared profiles for storage, identity, freshness, paid attempts and scoped Data views. Its two phase workers and common budget replace the generic self-fan-out pattern below for multi-phase enrichment. The profile tables are runtime tables in schema gtm; scripts/migrate.mjs migrates them with everything else, and upgrades preserve them.
Talk by the six rules in interaction; reproduce the dialogues. Workflow slugs follow the contract's slug rule; the display name is the doc comment's first line.
| Job | Do |
|---|---|
| Create | First use on a personal computer: run shared local setup from Connections, which creates an empty registry and prepares inspection without execution. Hosted authoring uses the already configured runtime. Start npm run viewer for inspection and npm run dev only for deliberate execution. Ask where the rows come from. When the workflow reaches people (an approval on a stage, or a notify call), ask which Slack channel it should post in, one question: a channel id (C0…, shown at the bottom of the channel's About tab) or "here", which means the channel of this conversation when the agent knows its id; the answer becomes a NOTIFY constant at the top of the workflow file, passed as notify to every stage and notify call, never a project variable. There is no per-workflow backend question: agent stages read GTM_AGENT_BACKEND from .env at run time (claude or codex for the author's subscription on a personal computer, unset for AI Gateway), so one file runs on the subscription locally and on the Gateway hosted. At scaffold on a personal computer, when claude or codex is on PATH and no Gateway key is present, set it once and say so; otherwise leave it unset. Author literal viewer.businessGraph using viewer, with labels and branches checked against the new code. Write workflows/<slug>.ts from example-scores.ts, or from example-research.ts when it has an agent stage, its table in db/tables/<name>.ts, register both in workflows/index.ts and db/tables/index.ts (keep the example workflows and their tables; removing a table in the same generate as adding one makes the generator ask a rename question that no host can answer), run npm run db:generate -- --name <name>, run npm run viewer:register once to assign the new workflow identity, rebuild, open its Diagram page, commit, close with what was created and the Open GTM Workflows entry. On a host without local runs: no localhost link, ever; after the push, read GET /api/link/<slug> on the deployed copy every 10 seconds, up to 3 minutes, until git merge-base --is-ancestor HEAD <commit> holds for the commit it returns, and only then post its verified viewerUrl; a reply written before that holds carries no links and says the deploy is under way. When the user asks to run, execute the requested scope after checking keys and caps, stating the estimate without another confirmation; when no connected project exists on a host without local runs, close instead by naming the connection steps in deploy.md. |
| Update | Change the workflow and its viewer.businessGraph together, including any new business decision branches and owner details; preserve viewer.id, validate the metadata with node scripts/build-viewer.mjs, refresh its criteria copy, add columns (then db:generate and restart); preserve existing data; close with what changed and the Open GTM Workflows entry. |
| Run | Target: local by default on a personal computer; the deployed copy when the user asks or the host states there are no local runs, which needs a connected project and, first, Deploy's readiness check. Pre-run check: the row count, rows × estimate against both caps, every connection the workflow uses against the target inventory in Connections, and whether the workflow is new or changed since its last run. A requested run, whether one or many rows, proceeds without a permission or test-first question: state the row count and estimated cost, then run the requested scope. A missing key or exceeded cap stops execution and names the blocker. Start through the route, poll the read route, close with the result in plain words: done, failed, skipped, cost; no links, unless they changed since the thread last showed them. When the read route lists a pending approval, stop polling and ask the person in one message (the tool, its input in plain words, the price when known), then decide it through the approve route with their answer and its reason, and resume polling; a run with stream on may be narrated from the stream route while it runs. When a local run follows a hosted one, say it may re-spend on rows the hosted copy already did. |
| Open UI | For “Open GTM Workflows UI” or an explicit link request, follow entry links. Resolve the discussed workflow by stable identity; without one, open the list. Check readiness. Locally start only npm run viewer if needed. Opening the UI never executes a workflow, runs a migration, or deploys a missing host. Report a concrete setup blocker when unavailable. Always return the entry when explicitly asked, even if already shown. |
| Share | Hosted only: read grants using /api/viewer/service?v=2&workflow=<id>&op=grants. Show the selected tabs and the current permitted-data summary before an explicit save. POST saveLink with {views, policy, save}. policy is the displayed hash; save: true explicitly approves scope or policy changes. Copy without changes reuses the link. Default is Diagram with no expiry. POST revokeGrant with the active ID to turn it off. Re-enable creates a fresh secret. Use the exact returned URL. See viewer. |
| Deploy | Deploy is the push; follow deploy.md for the one-time connection and the readiness check. A push never touches a run already in flight: it finishes on the code it started with. The hosted copy runs every agent stage through the Gateway whatever .env says locally. |
| Upgrade | Compare workflows/ to the installed templates/; say what differs; a workspace from before the Postgres runtime (it has db/tables/cache.ts) is not converted by this skill: say so and stop; update template-owned lib/, server/, viewer/, connections-ui/, viewer-server/, share-server/, scripts/, drizzle-runtime/, nitro.config.ts, drizzle.config.ts, drizzle-runtime.config.ts, tsconfig.json; copy skills/ from the template when the workspace has none; follow runtime reliability to merge dependencies and startup scripts into package.json, preserving user-added dependencies and custom commands, then copy the template's package-lock.json over the workspace's, delete node_modules, run npm install and confirm with npm ci --dry-run (installing over the old lockfile drops other platforms' packages and breaks the hosted build, and a lockfile generated from nothing is rejected by npm ci); regenerate workflows/index.ts, db/tables/index.ts, and skills/index.ts, preserving each workflow entry's viewer, data, intake, and other authored properties, including imported metadata files; never touch other files in workflows/, db/tables/, skills/, drizzle/, vercel.json, .env, data/; merge generated-asset and .env.* ignore entries; install the trusted component with shared setup --upgrade as Connections specifies; run npm run viewer:register only for entries missing an identity; build, start npm run dev once so the local database is migrated, then verify npm run viewer and the hosted private/share builds. Read each existing workflow and add or correct its businessGraph before the build. Preserve all workflow IDs. Restart the local server through npm run dev and verify its startup settings and a diagram page; hosted upgrades verify the deployed copy after the readiness check. |
| Delete | Remove the workflow file, its registry entry, and its vercel.json cron; keep the table and its data and say so; when a project is connected, say the hosted copy drops it and its schedule as the save deploys. |
For Create, Update and Upgrade, read viewer. Keep a valid business diagram and, for every Data tab, a valid viewer.sharePolicy. Build before saving; missing or mismatched sharing metadata is an authoring error. Preparing a policy makes Data selectable; changing a public link's access still requires an explicit sharing request.
Every save: one sentence on what will change, pull first when origin/main exists, edit through the host's write path, commit on main with a plain-language message, push when a remote exists, verify the commit (and that it reached origin/main when a remote exists), close with what was created, changed, or deleted.
On a host that states there are no local runs: the scaffold deletes env.example instead of renaming it, because the values live in the host's environment; every run targets the deployed copy after the push and after Deploy's readiness check; Upgrade verifies through the deployed diagram page after that check instead of restarting a server. Two rules hold on such a host whatever else the agent does:
- Links: a dev server or a local build may be started as a check, but nothing it serves is ever posted. The only links a reply may carry are the verified viewerUrl the deployed copy's link route returned after the readiness wait;
localhostnever appears in a reply. - Keys: read
GET /api/connectionsthrough the protected workflow transport for presence and declared usage, including unused services. Use the saved Note as the service name and the exact variable for code, following Connections naming. Direct missing-key entry to the verifiedconnectionsUrlfrom the private link route. Never collect keys in chat. Saved changes become active after a deliberate deployment; presence does not prove validity.
Conventions the code follows
-
Linked result tables: linked data defines the optional
dataregistry property and the read-only People/Companies-style viewer. Register it when users need to browse related records. Its private Data link comes from the same link route. Preserveviewer,data, andintakeduring upgrades. Sharing is an explicit private-viewer action; it requires a versioned table/column/relationship/row policy described in viewer. -
Step choice: every step is the plainest kind that does the job. Deterministic work (parsing, arithmetic, a fetch, a provider or gateway API call with known inputs, a Slack post) is a plain
"use step"function with no model. Judgment over text the workflow already holds (scoring, classifying, extracting, drafting) is a single-shot AI step,generateObjectwith a schema. An agent stage (runAgent) only when the user names an agent, or the work needs the model to choose its own tool calls as it goes (find the right endpoint, research across pages) and no sequence of plain and AI steps can be written in advance; a stage that would always call the same tools in the same order is those steps instead. When the user asks for an agent where steps would do, say so in one sentence and build what they asked. -
Rows come from
defaultInput(inline), a provider or API call inside a"use step"function, a table another workflow fills, or an inbound webhook (export const intake = defineIntake({ secretEnv, signature, eventId, toRow })fromlib/intake.ts, registered asintakeinworkflows/index.ts;POST /api/intake/<slug>verifies the sender's signature over the raw body, drops redeliveries by event id for 30 days, and starts one run with the mapped row); never from a workspace file or local-only data. Input isRowsInputfromlib/rows.ts,{ rows?, maxRows?, maxSpendUsd? }, each row an object with a required stringkey, the table's primary key; the workflow's constants are the defaults; the start route merges a POST body overdefaultInput, so the limited run isPOST { maxRows: 1 }. -
runRows({ rows, table, step, maxRows, maxSpendUsd, estimateUsd, freshForMs, concurrency?, fanOut? })fromlib/rows.tsis the loop;step(row)is a plain async function in workflow scope that awaits"use step"functions in sequence and returns{ ...columns, costUsd }. Tables are passed by name and resolved throughdb/tables/index.ts. Every workflow passesfanOut: { workflow: <its own function>, input, chunkSize }: above that many rows the run only splits the list into child runs of itself, four at a time, each with its exact share of the caps, and adds up their totals, so a list of any size is one run with one result.chunkSizeis 100 for a workflow of plain steps and 20 for one with an agent stage: the engine caps a run at 25,000 events and an agent row costs about 100 of them. Cancelling the parent cancels its children. -
Every result table has
key,updated_at,cost_usd,error. All database I/O, everycached()andgenerateObjectcall happen inside"use step"functions declared asasync function name() { "use step"; }, never arrow or method form; every paid or AI step setsname.maxRetries = 0. -
Agent stages: an agent inside a workflow is always
runAgent()fromlib/agent.ts, called from a plain async function in workflow scope (the stage function, anagentnode), neverWorkflowAgentdirectly and never inside a"use step"function; the helper makes every model call and tool call a step, races the time limit againstsleep(), cleans the schemas, and returns real cost. Its config is the whole authoring surface:modelandreasoning(defaultsGTM_MODELandGTM_REASONINGon the project),instructions,skills(names fromskills/index.ts, each a text module underskills/),tools(mcpservers by name withurl,keyEnv,allow,maxCalls;web.fetchfree, a step;web.searchexecuted by AI Gateway (trueor"gateway", Exa, any model, no key) or by OpenAI ("openai", openai/* models only), inside the model call rather than as a step, so it cannot be approved or call-limited;customtools whoseexecuteis a"use step"function),approve(tool names requiring destructive-action confirmation or an explicitly requested workflow review; never add routine approval gates by default; the row waits on a hook and the run's read route lists the request untilPOST /api/runs/<id>/approvedecides it; settimeoutin days when approvals may wait),stream(every model and tool event onGET /api/runs/<id>/stream),maxSteps,maxUsd(soft: the agent stops using tools after the call that crosses it and writes up what it has),timeout(a duration string; the row fails),schema(or none for plain text),promptormessages, and, for anything else WorkflowAgent takes,agent(constructor options: sampling, provider options,prepareStep,prepareCall, callbacks, telemetry, contexts) andcall(per-call options:toolChoice,activeTools, transforms). Schemas use nullable fields, never optional ones, and no string formats (z.string(), not.url()or.email()); the description carries the intent. Never passtimeout:orAbortSignal.timeout()to the SDK; never build MCP clients or tools by hand.example-research.tsis the reference. -
A single-shot AI step is
generateObjectwithGTM_MODELinside a step; anything that uses tools is arunAgentstage. The stage's backend is decided per run from the frozen environment:GTM_AGENT_BACKEND(claudeorcodex: the author's subscription, one step, this machine only; MCP servers and web tools carry over; codex needs each MCP server'sallowlist) or the Gateway when unset. A stage that uses custom tools,approve,stream, ormessages, and every stage on the hosted copy, runs on the Gateway regardless.backendon a stage overrides the variable for that stage only. No other AI path exists. -
Criteria:
export const criteria = \…`holds the ICP or persona text, first line naming it; backticks and${` are escaped. Nothing else from the workspace is deployed; the copy stays until the next Update. -
Doc comment: first line is the title, second paragraph the one-sentence summary; the diagram page reads both.
-
Viewer metadata: author literal
viewer.businessGraphwith stable node IDs, human labels, input/action/decision/output kinds, explanations and meaningful decision edges. Keep provider/caching details in explicit owner fields. Review the diagram against the actual workflow code on Create, Update and Upgrade; preserveviewer.id. See viewer for the schema and validation. -
Where to look: follow entry links after create/change, deploy/upgrade, ordinary runs, and explicit requests. Return the canonical private
viewerUrlas Open GTM Workflows. This is separate from issuing an external share grant. -
Connections: read the target inventory before writing a paid or AI step and in every pre-run check. Name a missing connection once and open the trusted Connections form; use its workflow-declared gateway relationship when deciding which key is required. Author literal
viewer.connectionsalongside the business graph. Follow Connections for unknown keys, activation state and safe credential entry. -
Gateways: a paid step that uses a provider found through a gateway tool the host exposes (a data marketplace, an MCP server) calls that gateway from code with the gateway's own key,
<GATEWAY>_API_KEYmanaged through Connections, never the provider behind it. When the gateway's HTTP API is unknown, read its documentation before writing the step; do not guess. -
Engine features are used natively (see local.md): a hook or
sleepis awaitnode, a child workflow asubnode, arunAgentstage anagentnode. -
Reaching people:
notify({ kind, text, approval?, target })fromlib/notify.tsposts to the GTM agent's notify route, which posts the text straight to Slack with the agent's bot token and no model call;tellandshoware plain posts,askandhandoffopen a message whose thread the agent watches, so a person's reply there wakes it. An approval in a stage notifies by itself whenGTM_AGENT_URLandGTM_NOTIFY_SECRETare on the workflow project. Where it posts is code:const NOTIFY = { channelId: "C0…" }at the top of the workflow, given asnotify: input.notify ?? NOTIFYto each stage and astargetto eachnotifycall; posts land top-level in that channel, never in the conversation a run was started from. Per-row news goes throughrunRows, never anotifycall inside the step:notify: { target: input.notify ?? NOTIFY, every: "chunk", line: (row, columns) => … }, whereeveryisrow(one post per finished row),chunk(one post per run of rows, so one per child when fanned out; the default to propose) orrun(one post with the totals at the end), a count and never a judgment;linereturns a string, or{ text, blocks }when a row deserves a rich post.notify: falseon a stage keeps its approvals silent. The agent project'sGTM_NOTIFY_CHANNELis only the fallback when a workflow names none. A workflow calls the agent only to reach people, never to think. -
Helpers are admitted to
lib/only when a bare SDK call was shown to fail or double-spend; everything else is a native SDK call with a worked example in patterns. -
Routes, bearer
GTM_RUN_SECRET:POST /api/run/<slug>,GET /api/run/<slug>(cron, alsoCRON_SECRET),POST /api/intake/<slug>(no bearer; the sender's signature),GET /api/runs/<id>(status, output, error, andapprovals, pending first),POST /api/runs/<id>/approvewith{ token, approved, reason? },GET /api/runs/<id>/stream(one JSON event per line while an agent stage streams),POST /api/runs/<id>/cancel,GET /api/link/<slug>. Base:http://localhost:3939locally,GTM_WORKFLOW_URLfor the deployed copy. WhenGTM_RUN_SECRETis the valuehost, the host supplies the credential outside the agent's reach and the call sends noAuthorizationheader.
Outputs
workflows/<slug>.ts, its table and migration, registry entries, a vercel.json cron when scheduled, a diagram page, rows in the result table, and, once a project is connected, the deployed copy that every push refreshes.
For queue, provider, MCP, or CLI failures, read runtime reliability before attributing the cause or proposing a retry.
Exceptions
Requires the gtm-workspace skill installed alongside this one; when ../gtm-workspace/SKILL.md is missing, say: install it the same way this skill was installed, with npx skills add eliasstravik/gtm-skills -s gtm-workspace -y (add -g when this skill lives in the global skills directory), then retry. A missing key stops a run before it starts and is named. A production build on Vercel with no DATABASE_URL fails on purpose; report it and say the database is added through the Neon integration, as deploy.md describes. A run on the deployed copy needs a connected project; without GTM_WORKFLOW_URL, say how to connect one as Deploy does (shared standalone setup on a computer with the CLIs signed in; gtm-agent is optional) instead. A workflows/ that contains scripts/gtm.ts is the previous runtime, which this skill neither converts nor upgrades: say so, and offer to set it aside (remove workflows/, which stays in the workspace's history; its deployed copy keeps running until the next push) and scaffold fresh.
QC
- Every workflow has a valid business graph whose labels, branches and results match its actual code. Business behavior changes update metadata in the same change. The metadata build passes; compiler graphs never substitute for missing business metadata.
- Every step is the plainest kind that does its job: no AI step for deterministic work, no agent stage where plain and AI steps in a fixed order would do, unless the user named an agent.
- A one-row test was offered, through the question tool, before the first run of more than one row of a new or changed workflow; a one-row request ran without that question; the cost statement preceded every run.
- The result table has the four fixed columns; paid and AI steps have
maxRetries = 0; nocached()orgenerateObjectcall sits in workflow scope. - Every agent stage is a
runAgent()call in workflow scope withmaxSteps,maxUsd, andtimeoutset; noWorkflowAgentimport, notimeout:option, noAbortSignal.timeout, no.url()or.email()in a schema, no optional fields in an output schema; a new or changed agent stage was tested on one row and the reply named its tool calls, stop reason, and real cost. - Every workflow passes
fanOutwith itself,chunkSize20 when it has an agent stage; a module a workflow imports never reaches the database outside a"use step"function; route-only code lives in*-api.tsfiles. - An ordinary Run reply ends with the result and repeats no entry unless its address changed or the user explicitly requested it; a save ends with what was created, changed, or deleted.
References
interactions dialogues; local.md for start, keys, data, schedules, tables, the viewer, the agent stage, fan-out, intake, notify, and the findings ledger; patterns for native SDK calls with worked examples; deploy.md; templates/workflows/example-scores.ts as the reference implementation and templates/workflows/example-research.ts as the agent-stage reference; from gtm-workspace: interaction, contract.