user_prompts row. Each
run’s result lands as a new conversation in the user’s thread list, where
they can read it and ask follow-ups. What the agent carries from one run to
the next is the user’s working memory, when the agent keeps one — see
Memory.
Everything the assistant creates is also reachable over HTTP, so your product
can show a user their workflows, run one from a button, and let them pause or
reschedule — with the same session token the chat widget already holds.
How the pieces fit
The building blocks a user may put in a workflow are exactly what is
attached to the agent they are talking to — its tools, its toolkits (Slack,
Gmail, …) and any organization workflows attached to it. Nothing else exists
from the user’s point of view: an agent with only a CRM tool and Slack can
only build CRM-to-Slack automations, and an agent in a network never borrows
another agent’s tools.
What every agent gets
1
The builder tools are already there
Every agent carries the workflow-builder tools; there is nothing to
attach and they are not listed by
GET /v1/tools. They stay out of the
agent’s prompt until the conversation turns to automations: the agent
sees one small tool, open_workflow_builder, calls it, and from the next
step on it has the tools below and the platform’s authoring guidance.
Idle conversations pay for one tool schema and one sentence.2
Attach the building blocks
Attach the tools, toolkits and organization workflows the user should be
able to automate with to this agent — the agent builds with those and
nothing else, and it already knows their names and input schemas. For
toolkit actions the user’s own account must be connected; the agent gets
list_connections and connect_toolkit for that, and
schedule_user_workflow refuses with missing_connections until it is.3
Publish the agent
Publish and activate as usual. Once the builder is open, the platform adds
its own guidance block to the agent’s system prompt: the authoring
contract, the list of blocks it may use, and the working agreement below.
You do not need to explain any of this in your own prompt.
The agent’s part is deliberately small: find out what the user wants
automated, do it once live in the conversation (the read steps only:
fetch, list, search — never a send or a post), then save the prompt that
reproduces what it just did. Nothing is compiled and there is no code, so
there is no data shape to get wrong: at run time the agent works from what
its tools actually return, retries a failed call, pages through results.
The save pauses the conversation and shows the workflow card (below):
the prompt, and in its footer Test and Approve — manual only, or
with a schedule picked from the frequency dropdown next to the buttons. A
test is optional. A test is nothing more than the automation’s
instructions sent to the agent as an ordinary turn of this conversation,
marked as a test: a test is a real run, every step done exactly as a
scheduled run would do it, actions included, and its
answer streams below the card like any answer. When it ends the card is
shown again under it, so the user approves from there. To change anything the user simply replies in the chat: the
agent updates the prompt and shows a new card, which replaces the old one.
The agent never asks about timezones or run times: all of that happens in
the card, and the user’s decision comes back to the agent as a tool
result.
The workflow card
save_user_workflow does not just return: after validating the spec it
suspends the agent’s turn with the card as its payload, exactly like a
tool waiting for approval. Nothing is written to the database at that point:
the card carries the automation itself (name, prompt, components, the
schedule to preselect). The stream emits a data-tool-call-suspended part
and ends; the SDK renders that part as the card; the user’s decision goes
back as the tool’s resume data; the tool runs again with it — from its own
input, the spec the user reviewed — and its result is an ordinary tool
result in the thread. Approve is the only write: one user_prompts row
is created (or the saved one updated). So the agent reacts to what the user
did, and every later turn sees it in history — no side channel, no
synthetic messages, no drafts, no workflow rows.
Every card offers the same two actions in its footer — Test, and
Approve with a frequency dropdown whose first choice is Manual only
(run on demand); any other frequency turns the button into Approve &
schedule and opens the time, timezone and result-thread-title fields. A
line above the buttons says that a change is asked for by replying in the
chat. Whatever the user does, the footer becomes the receipt of it.
The card has two stages, carried as stage on the payload and on the card,
which only change its badge and wording:
draft— shown bysave_user_workflow: the proposal, not yet tested. (draft_revis null: nothing is stored.)tested— the card shown again after a test. A Test is not a resume and not a workflow run: the chat route answers it as an ordinary turn of the same agent on the same thread, with the automation’s prompt as the message (framed as a test), so everything a normal answer has applies — the agent’s tools, the rendering tools, streaming, memory. When the answer ends the route emits thedata-tool-call-suspendedpart again for the samerunId/toolCallId(the run is still suspended; Approve resumes it later), withstage: "tested"andtest: { status: "succeeded", duration_ms, checks? }— orstage: "draft"andtest: { status: "failed", error, message, checks? }when the turn errored or a check failed.checkslists every check with its verdict ({ check, passed, detail, auto? };automarks the ones added for thecomponents), so the card can say what failed — “calls showTable: called 0 time(s)” — and a failed check never blocks Approve. The answer and that second part land in the message the card is in, so the SDK keeps showing ONE card: the test’s answer appears inside it, above its actions, and the card offers Test again and Approve. Every further test of the same card adds a result the card shows as a branch (a1 / 2picker, like a retried message) — no new card. The route reads the instructions from the pending tool call stored with the thread, so the test needs nothing in the database. (To show the card again on demand, the agent proposes it again.)
agentVersionId and threadId are
added by the API so the client can round-trip them):
prompt is the automation itself — the message the assistant receives at
every run, in markdown (a numbered list when it detailed steps) — and the
card renders it as prose. components are the rendering tools the answer
must use, shown as tags. steps, input_schema and test_input are kept
empty for clients built against the earlier step-based card. frequencies
lists the schedule picker’s choices; manual means approve with no schedule.
After a test the card comes back with:
test is the outcome of the test turn that just ended. The turn itself is
persisted like any turn: its user message (the instructions) is tagged —
with everything but the outcome from the start, the outcome when the turn
ends — and hidden by GET /v1/messages, which merges the answer that
followed — the card part after it — into the message the card is in, so a
reload reads exactly as it did live: one assistant message holding the card
and, after it, each test’s answer closed by the card part again. A test
whose end was never recorded (the turn cut short) comes back closed with
test: { status: "interrupted", message } once a later message shows the
turn is over; the newest one stays open.
The card shows the name, the prompt and the components, and offers Test and
Approve. Each one is the same POST /v1/chat continuation the
approval card already uses — empty messages, the chunk’s runId,
toolCallId and agentVersionId, the same threadId — with the decision in
approval.data. (Without agentVersionId the API recovers the agent that
showed the card from the run, so a card reopened from history still resumes
on the builder agent.)
finish. An
approval is not an agent step — the model is not called again, nothing is
said, and no follow-up suggestions are made: the card itself is the receipt
(the tool result carries what was saved and, for a schedule, its next run
times). The result stays in the thread as the card’s tool part, so the
agent’s next turn sees in its history that the automation was approved and
scheduled. Use the browser’s timezone
(Intl.DateTimeFormat().resolvedOptions().timeZone) and preview the run
times first with GET /v1/workflow-schedules/preview if you want to show
them before the user commits.
A few things follow from this being a suspension:
- While the card is pending the turn is paused. If the user types instead of using the card — the way a change is asked for — the new message starts a fresh turn; the old card stays in history as a pending call, rendered without its buttons, and the agent saves again (a new card) or shows the card again (a new proposal) when asked.
- Each proposal is its own tool call, so a new proposal after a comment is a
new card further down; older cards for the same automation (same
id— aproposal:<name>id until it is saved, the prompt id afterwards) should render read-only. A Test re-suspends the SAME tool call, in the same message: render the first suspension as the card, the parts that follow it as that card’s test results (each run of parts ends with the next suspension of the call, which carries its outcome), and the newest suspension’s payload as the card’s state. - A card shown again after a reload is rebuilt from the thread’s metadata
with the same
suspendPayload, so it can be rendered and answered exactly like the live one.
What an automation looks like
The agent authors a small spec: a name, a description for the user, the prompt it will receive at every run, and the rendering tools its answer must use.
A run is one chat turn of the agent that built the automation: the prompt
is the user message, the result conversation is the memory thread, and the
agent answers with exactly what it has in the chat — its instructions, its
tools, its memory, its step budget, the user’s stored credentials. Three
things follow:
- No code, no shapes. The agent never transcribes a tool’s output shape into code. At run time it reads what the tool returns, adapts, retries a failed call, pages through results — the same way it answers in chat. A small model that can do the task live can run the automation.
- Its own tools only. The run uses exactly the tools, toolkits and organization workflows attached to the agent, with the user’s stored credentials. A tool named in the prompt that the agent does not have is simply not there. The only difference from a chat turn is that the workflow-builder tools are hidden (an automation never builds automations).
- A test is a real run. Testing from the card sends the same instructions to the same agent in the same conversation, actions included, with the workflow-builder tools hidden as on a scheduled run, so the user sees what it really does before approving — a test, a chat reply and a scheduled run are the same turn.
user_prompts row (name, description,
prompt, components). GET /v1/prompts/{id} returns the row.
Schedules
A schedule is structured, never a free-form cron. The agent and the user agree on arecurrence in the user’s IANA timezone:
Every schedule also has:
timezone— an IANA name such asEurope/Paris. All times are local to it.starts_at/ends_at— optional window. A schedule whose window has passed, or aoncethat has fired, becomescompleted.input— the workflow input for every run (the real recipients).notify—thread(default) files each result as a conversation;noneonly logs the run.thread_title— the title of every result thread, e.g. “Daily signups digest”.
GET /v1/workflow-schedules/preview (or the agent’s preview_schedule)
answers the next run times without creating anything. The recurring minimum is
hourly by construction, and the same minimum is enforced on the raw cron
that backend callers may send instead of a recurrence.
Lifecycle
How runs happen
The API polls for due schedules every 30 seconds and claims each one atomically, so two API instances never double-fire. A few rules keep unattended runs sane:- Missed slots collapse to one. If the server was down through several fires, the schedule catches up once, from now.
- Stale slots are skipped. A slot more than 10 minutes late is logged as
skippedwithskip_reason: "misfire"rather than firing a stale 08:00 digest at 14:00. - No overlap. If the previous run is still
running, the new slot is skipped withskip_reason: "overlap". - One retry for transient failures. Anything but an auth, usage-limit, validation or not-found error is re-attempted once after 10 minutes, in the same result thread. The “failed” message is only posted when the final attempt fails.
- Disabled after 5 consecutive failures. Only final outcomes count. A successful run resets the counter.
status (running, succeeded,
failed, skipped), the text that was posted, any error, the thread_id
of its result thread, and a request_id that joins it to its trace.
Result threads
Whennotify is thread, each run creates a real conversation for the user
before it starts — so a long run is visible immediately — and the agent’s
turn runs in it. The thread opens on a one-line user message saying when the
run fired and on what rhythm (“Scheduled run of Morning digest (every
weekday at 08:00 Europe/Paris) fired at …”); the automation’s instructions
reach the agent as system context, not as a pasted prompt in the
conversation. The answer is persisted exactly as a chat turn is: every tool
the agent called (the API fetches, each with what came back), the rich-UI
components (charts, tables, KPI cards, the weather widget) the automation
asked for or the agent chose to add, then the text — in the same shape the
chat widget already renders, so a run reads like a test, the fetch first
under “Used N tools”. If the agent that built an automation is deleted or
unpublished, its runs fail with a message saying so.
The run log row (GET /v1/workflow-runs/{id}) carries the same text and
components, plus the input the run received.
These are ordinary threads with a little extra metadata:
metadata.sourceis"workflow". Filter withGET /v1/threads?source=workflow.metadata.unreadistrueuntil the user opens it. Filter with&unread=truefor a badge;POST /v1/threads/{id}/readclears it.metadata.runStatustracksrunning→retrying→succeeded/failed. While it isrunning, the thread’s live stream can be resumed (GET /v1/chat/stream?threadId=…) to watch the run — see Watch a run as it happens below.
POST /v1/chat routes to the agent
that built the workflow, with the run — the data it fetched included — in
its history, so “why is Tuesday missing?” just works.
Each run is a fresh conversation: nothing thread-scoped — the history, the
observer’s log of that thread — carries from one run to the next. What does
carry is whatever the agent keeps per user: its working memory at
resource scope (the default scope), which a run reads exactly as a chat
does, since a run executes as its owner on the owner’s user record. With the
observer maintaining those notes at the end of every turn
(observation.manageWorkingMemory + observation.bufferOnIdle), Tuesday’s
run knows what Monday’s learned, and so does the chat where the user asked a
follow-up. The combination is spelled out in
Memory.
Credentials
Runs execute unattended, so they cannot use the credentials on a live request. They read the user’s stored tool credentials — the same store a token mint orPATCH /v1/users/{userId} writes (see
Tool credentials). When the
agent schedules a workflow it captures the credentials the current request
carries for the providers the workflow uses, so a schedule created mid-chat
is ready to run.
There is no fallback: a run whose user has no stored credentials for a
provider the workflow needs fails as an auth error, and repeated auth failures
disable the schedule with a reason that says so. Fix it by pushing fresh
credentials from your backend (PATCH /v1/users/{userId}) and resuming the
schedule, or by asking the assistant to refresh and resume.
Toolkit actions (Slack, Gmail, …) use the user’s connected account, not a
credential you push. If the user disconnects it, runs fail the same way and
reconnecting through the assistant fixes them.
Building your own UI on it
Your frontend can do everything the assistant does, with the session token it already holds. Every call is scoped to the session’s user: they see only their own workflows, schedules and runs, and anything else answers404.
Creating a schedule from the browser has three rules, all enforced server-side
so the body cannot be used to act as someone else:
- The schedule is created as the session’s user.
user_idmay be omitted, or must match — naming anyone else is a400. - The billing group comes from the session, never from the body.
- The target is one of the user’s own automations (
prompt_id) or a published organization workflow (workflow_id). Another user’s automation is a404.
description (“every weekday
at 08:00 Europe/Paris”), next_fire_at, next_fire_local, and the next three
run times.
Running from a button is a single call:
notify_thread — streams into that thread while it runs, exactly like a
chat turn. The thread appears in GET /v1/threads (and on the /v1/events
feed) with metadata.runStatus: "running" as soon as the run starts; a
client that opens it resumes the live stream with
GET /v1/chat/stream?threadId=… — the same call that resumes an interrupted
chat turn — and receives the assistant’s tool calls, components and text as
they are produced, then finish when the result is written. The stream opens
with a transient data-resume-state part: { "done": false } while the run
is going (apply everything that follows, the turn is not in history yet),
{ "done": true } when a finished run is replayed within its retention
window (the thread’s history already has it). The chat widget does this by
itself when a conversation is opened. Once the run is over, the
thread reads back through GET /v1/threads/{id}/messages as the same message
the viewer watched. DELETE /v1/chat/stream?threadId=… stops a running
automation from its thread; the run is logged as failed with a “stopped”
message and is never retried.
From your backend
With the organization API key the same endpoints work org-wide, plus a few things a browser cannot do:GET /v1/prompts?userId=…lists one user’s automations (an API key must name the user);GET /v1/workflow-schedules?owner=chatterlists every user-owned schedule in the organization,&userId=narrows to one user.POST /v1/workflow-schedulestakes a requireduser_id(the user the runs execute as: billing group, integrations, result-thread owner), an optionalgroup, and accepts a rawcroninstead of a recurrence. Use it to schedule any published workflow — yours or the user’s — for a user without a conversation.GET /v1/workflow-schedulesandGET /v1/workflow-runsreturn the whole organization, filterable byworkflowId,userId,status,owner(org|chatter) and, for runs,trigger.
owner_kind: "org"), even when they run as a user. They are managed with the
API key and do not appear in that user’s session-scoped listing; schedules the
assistant or the user’s own session create are owned by the user and do.
Every endpoint here is also an MCP tool, so an operator’s
assistant can inspect and manage schedules the same way.