Workflows

Build workflows from a description, read them in full, run, pause and publish them, or give them an endpoint.

A workflow is an automation: a trigger, the steps it runs through its integrations and agents, and what it outputs. NoClick's builder (the one in the app) builds and changes it from what you describe, from your code or from the app, and people who know the process can open it there and change it too. Your code runs it and reads the result, without copying the steps into code.

Build one from a description

import noclick

brief = noclick.Workflow.create(
    "Morning brief",
    instructions="Every weekday at 8, email me today's calendar and the unread emails that need a reply.",
)
result = brief.latest_build.wait(timeout=900)

print(result.summary)                        # what the builder says it made
print([step.name for step in result.steps])  # ["Every weekday at 8", "Read the calendar", ...]

create makes the workflow in your account and, with instructions, starts a WorkflowBuild: an operation whose wait() gives the builder's summary and the workflow's steps once it's done. A name is unique in your account: create with one you already have raises noclick.Conflict.

Change a workflow the same way, by describing the change. The builder reads the workflow as it stands, including what someone changed in the app:

brief.build("Also post the brief to #team on Slack").wait(timeout=900)

One build runs at a time per workflow; another raises noclick.Conflict until it's done.

Publish its app

A workflow with an interface (a page, a form, a dashboard) can go live at its own address. publish() puts it live as it stands, and wait() gives the URL:

site = noclick.Workflow.from_name("Morrow Bakehouse site")
build = site.publish(subdomain="morrow", title="Morrow Bakehouse")
print(build.wait().published["url"])   # https://morrow.noclick.app

Leave out subdomain to publish again at the address it has. To change it and put the change live in one go, pass publish to the build: site.build("Add our Saturday hours", publish=True). unpublish() takes it down; to move a live app without publishing what changed since, build with publish={"action": "rename", "subdomain": "morrow-bakery"}. Publishing puts something on the internet, so a connected app needs the deploy capability to do it.

When the builder asks

The builder asks rather than guesses: which channel, what time, which account. While it waits, the build is awaiting_human and question says what it needs. next_question() returns as soon as there is one, and answer() takes the answers by input id:

build = brief.build("Also post the brief on Slack")
while (question := build.next_question(timeout=900)) is not None:
    if not question.answerable_from_code:
        print("A person answers this:", question.answer_url)
        break
    build.answer({"channel": "#team"}, message="Keep it under five lines")
print(build.wait(timeout=900).summary)

An input of type credential (an account to connect) or env (secret values) is a person's to answer: send them to answer_url, a page where they connect it or type it in, and the build goes on once they do. answer(skip=True) goes on without answering.

From an agent, the MCP server's workflows_save tool does the same: build or change a workflow by name, answer what it asks, then wait.

Run a workflow

import noclick

intake = noclick.Workflow.from_name("order-intake")
run = intake.run({"order_id": "o_1042", "rush": True}).wait()

print(run.status)               # succeeded
print(run.outputs["summary"])   # what its step named "summary" produced

from_name refers to the workflow by its name in the app, and sends nothing until the first call. If two workflows share the name it raises noclick.Conflict: rename one, or use noclick.Workflow.from_id.

run takes the workflow's inputs as a dict and returns an operation straight away. wait() gives a WorkflowRun once the run finishes: its status, its outputs, and when it started and finished. outputs holds what its final steps produced, each step that finished with nothing after it running, by the step's name in the app (its id when it has none or shares it with another final step).

A run starts where the workflow's trigger does, and runs only what that trigger leads to:

  • its Run trigger, if it has one: the one the app's Run button starts;
  • else its one trigger a run can start from code: a webhook or a form, which get your inputs as what was sent to them, so the run is the one a request to the webhook's URL or a submitted form would start; a schedule, which fires now; or an integration it polls, which checks now.

A workflow with no trigger runs every step. A switched-off trigger starts nothing. One that starts only on events an integration sends, like a Slack message, or from several triggers and no Run trigger to say which, raises noclick.ActionRequired: add a Run trigger in the app where runs from code should start.

What it takes

A workflow's inputs is a JSON schema of what its trigger takes: a form's fields, with their types and which are required; any JSON object for a webhook; and nothing, an empty schema, for the rest.

intake = noclick.Workflow.from_name("order-intake")
print(intake.inputs)   # {"type": "object", "properties": {"order_id": {"type": "string"}, ...}, "required": ["order_id"]}

A form field that keeps a value in the app, its default or one saved in the form, isn't required: left out, the run uses that value. Inputs that don't match raise noclick.InvalidRequest naming the field, before anything runs. A workflow whose trigger takes nothing runs with run().

When it fails

What would stop a run is refused before anything runs: noclick.NeedsConnection for a step with no account connected (connect one to it in the app), noclick.ActionRequired for one that isn't set up, and noclick.SpendCapExceeded or noclick.PaymentRequired once a cap or the balance is reached.

A run that fails later raises its error from wait(), naming the step that failed and saying why in that step's own words; next_step links to the run in the app, where you can see what each step did.

try:
    intake.run({"order_id": "o_1042"}).wait()
except noclick.Error as error:
    print(error.message, error.details.get("step"))

A failed run, the ones a schedule or a webhook started included, also sends a workflow.run.failed event. An agent can watch over your workflows with it, look into each failure and pause one that keeps failing:

ops = noclick.Agent.from_name("ops")
ops.add_trigger(noclick.OnPlatformEvent("workflow.run.failed", conversation="workflow-{data.workflow_id}",
                                        message="A workflow failed. Find out why; pause it if it's useless."))

Don't wait on it

A long run doesn't need your process to stay up. Keep the operation's id and come back to it, or have a webhook tell you when it's done (operation.succeeded and operation.failed, with the run in data.result):

op = intake.run({"order_id": "o_1042"}, idempotency_key="order-o_1042")
job_id = op.id

run = noclick.Operation.from_id(job_id).wait()

idempotency_key makes a retried request start one run, not two.

A run can wait inside the workflow too. While a step waits for someone's approval, its operation is awaiting_human; while it waits out a delay, or for an agent on a coding harness (Claude Code, Codex and the rest) to answer, it stays running. When the agent answers, the rest of the workflow runs as part of the same run, and its outputs include what came after the agent.

Run it for a user

A workflow belongs to your account, but a run can be recorded for a user, like any other call. The run counts toward that user's spend cap and shows under the user in activity. Its steps still use the connections attached to them in the app.

with noclick.as_user("acme"):
    run = intake.run({"order_id": "o_1042"}).wait()

Recent runs

for run in intake.runs(status="failed", user_filter=noclick.ALL_USERS):
    print(run.started_at, run.source, run.error)

To see what each step did in one run, read it by id. steps comes in the workflow's order, each with its status (succeeded, failed or skipped), its error and what it produced (long lists and text cut short):

run = intake.get_run(run.id)
for step in run.steps:
    print(step.name, step.status, step.error or step.output)

runs() lists the 20 most recent runs however they started: source is api and endpoint for runs from your code, schedule, trigger for an event (a webhook, an email, a message to its agent) and app for one started in the NoClick app. Pass limit= for more. Outside as_user you read the runs recorded for your account, which are every run not started from code for a user; user_filter reads your users'.

Give it an endpoint

endpoint = intake.endpoint(public=True)
print(endpoint.method, endpoint.url)

endpoint() turns on the workflow's HTTP endpoint: POST the inputs as a JSON body and the response is the run's outputs, or the run's error. It waits up to 30 seconds for the run; a longer one answers 202 with the run's operation, which GET {url}/runs/{operation id} answers like the call would have once it's done (or noclick.Operation.from_id waits on, with a secret key).

curl -X POST "$URL" -H "Authorization: Bearer $NOCLICK_API_KEY" -H "Content-Type: application/json" \
  -d '{"order_id": "o_1042"}'

A private endpoint (public=False, the default) needs a secret key with the workflows scope, like any API call. A public endpoint also takes a publishable key that lists it, so a web page can call it without a secret, and each visitor is rate limited. Calling endpoint() again with public= changes it; without it, it keeps what it is.

Find workflows

for workflow in noclick.Workflow.list():
    print(workflow.name, workflow.access, workflow.updated_at, workflow.app_url)

Your keys read every workflow your account can open in the app: its own, and those shared with it, with a team workspace it belongs to, or through a shared folder. access says which: owner, editor or viewer. Changing, building, running, pausing and deleting take owning a workflow and paying for it: one in a team workspace someone else owns bills them, so it's changed and run in the app, and one shared with you is its owner's to change. Either refuses with noclick.PermissionDenied. from_name finds your own workflow by that name before one shared with you.

Read one in full

One workflow reads with everything about it:

intake = noclick.Workflow.get("2e3a9296-1bf5-4088-a20d-7c31937380c4")
for step in intake.steps:
    print(step.name, step.enabled, step.settings, step.connection, step.trigger)
print(intake.readiness)   # {"status": "needs_setup", "issues": [{"step": "ticket", "code": ..., "message": ...}]}
print(intake.edges, intake.urls, intake.paused)
  • Each step's settings are what it's set to in the app, never a secret (keys, tokens and passwords are left out), and long values are cut short.
  • connection is None for a step that takes no account, else whether one is attached and healthy (None when NoClick can't tell), with a hint when it isn't.
  • A trigger's trigger says what keeps it from firing (error, paused_reason) and when it fires next (next_run); other steps have None.
  • edges say how steps are wired, kind tools for a step an agent uses as a tool.
  • readiness is whether it's set up to run, and the issues in the way.
  • urls are where it answers: its endpoint, its published app, a hosted mcp server. A hosted MCP link connects to Claude Code, Codex, Cursor or any other MCP client the way a hosted MCP server's URL does.

A list leaves the steps out; read one workflow for them.

Pause and resume

digest = noclick.Workflow.from_name("Hourly digest")
digest.pause("Too noisy over the weekend")
digest.resume()

pause() switches off each of the workflow's triggers that's on, with your reason beside them in the app: nothing starts it on its own (schedules, webhooks, events) until you resume(), which switches back on only what the pause switched off, never a trigger someone turned off by hand. Runs you start from code still run.

Trash and restore

delete() moves a workflow to the trash: its schedules and webhooks stop at once. restore() brings it back within 30 days, and they register again:

noclick.Workflow.from_id("2e3a9296-1bf5-4088-a20d-7c31937380c4").restore()

Limits and billing

A build is AI usage, as building in the app is. A run is billed like the steps it runs: model tokens, tool runs and the rest, as in Spend and limits, to the key and user that started it. What a run does after it waited (an approval, a delay, an agent's answer) is billed to your account but doesn't count toward the key's or the user's cap. Inputs are up to 256 KB.

See the reference for Workflow.create, Workflow.build, Workflow.publish, Workflow.run, Workflow.runs, Workflow.get_run, Workflow.pause, Workflow.restore and Workflow.endpoint.