Skip to content

Reference

Script reference

The Python runtime: handlers, payloads, state, connections, notify, HTTP, signature checks, retries, retention, packages, limits and error codes.

View as raw markdown

This page describes what an automation's script can do. In normal use your AI assistant (ChatGPT, Claude, or any other client connected over MCP) writes the script for you, following this reference. You only need it to read a script under Advanced, or to ask for a precise change.

Runtime#

  • Language: Python 3.11. Each automation runs on its own, separate from other automations.
  • Network and processes: outbound connections only over HTTPS (port 443) to public addresses. No http:// URLs, other ports, raw sockets, UDP or listening sockets, and no subprocess, os.system, multiprocessing or ctypes. A script that does this is rejected before it goes live, and blocked at run time with an error such as "Outbound connections are allowed only over HTTPS (port 443). Port 25 was blocked."
  • Entry point: your script is packaged as user_script.py and imported by the ClickRuntime runtime. You only write handlers (see below).
  • SDK: import clickruntime (preinstalled).
  • Time limit: each run has a time limit (180 seconds by default). Memory is 256 MB by default.
  • Secrets and connections: nothing secret is in the environment. The script asks for each one with clickruntime.credentials while it runs (see Secrets and connections).

Handlers#

A script registers handlers with decorators. At least one of @on_trigger or @on_event is required.

DecoratorCalled whenArgument
@on_triggerA schedule fires, or someone presses Run now (ClickRuntime dashboard or chat)Event with type = "schedule" or "manual"
@on_eventA request arrives at the automation's trigger linkEvent with type = "incoming"
@state_initializerOptional. Sets up remembered state before the first run, and again after the state is clearednone
@state_cleanerOptional. Runs when state is cleared, just before the keys are deleted. For side effects only: the keys are always deletednone

The handler's return value (any JSON-serializable value) is stored as the run's result.

from clickruntime import on_trigger, state, log, notify, http, credentials

@on_trigger
def run(event):
    token = credentials.secret("SHOP_TOKEN")
    resp = http.get("https://api.example.com/price", headers={"Authorization": f"Bearer {token}"})
    price = resp.json()["price"]
    last = state.get("last_price")
    state.set("last_price", price)
    if last is not None and price < last:
        notify.email_me("Price dropped", f"Now {price} (was {last})")
    log.info("checked price", {"price": price})
    return {"price": price}

Schedule and manual payloads#

@on_trigger receives:

{"type": "schedule", "scheduled_time": "2026-01-01T09:00:00Z"}

scheduled_time is the UTC time the run was due, not when it started. A manual run receives {"type": "manual"}, possibly with "requested_by": "assistant" | "website". When an assistant asked for the run, "requested_via" names it, such as "ChatGPT" or "Claude Code".

Schedules#

Schedules are stored in plain terms, not cron expressions:

KindFieldsExample description
every_minutesevery_minutes: 5, 6, 10, 12, 15, 20 or 30Every 15 minutes
hourlyminute (0–59)Every hour at :30
dailytime (HH:MM), timezoneEvery day at 09:00 (Asia/Kolkata)
weekdaystime, timezoneWeekdays at 09:00 (Europe/London)
weeklyweekday (mon…sun), time, timezoneEvery Monday at 08:00
monthlyday_of_month (1–28, or "last"), time, timezoneMonthly on the 1st at 09:00
cronexpression (5-field cron, or 6-field cron with L, W, #), timezoneAt 09:00 on the second Tuesday of the month

Timezones are IANA names such as America/New_York, and schedules run at that local time, including across daylight saving changes. The shortest interval is 5 minutes; one-minute schedules are not available, and a cron expression must keep its runs at least 5 minutes apart.

@on_event receives an Event built from the request:

{
  "type": "incoming",
  "received_at": "2026-01-01T09:00:00Z",
  "event": {"id": "evt_1", "type": "checkout.session.completed"},
  "headers": {"content-type": "application/json", "stripe-signature": "t=...,v1=..."},
  "raw_body": "{\"id\":\"evt_1\",...}",
  "event_id": "ev_01J...",
  "verified": {"provider": "stripe", "ok": true}
}
FieldMeaning
eventParsed JSON body, or null if the body isn't JSON. Form-encoded bodies may be parsed into an object.
headersAll request headers, lowercase keys. Passed unmasked to your script so it can verify signatures; masked everywhere ClickRuntime stores or shows them.
raw_bodyThe exact request body as text. Use it for signature checks.
event_idClickRuntime's id for this delivery, stable across retries. Use it to avoid handling the same delivery twice.
verifiedResult of platform-side signature verification when configured, otherwise null.

Trigger links have the form https://clickruntime.com/in/<key> and accept POST bodies up to 1 MB of UTF-8 text (other encodings get 415 invalid_encoding). A delivery returns 202 {"ok": true, "event_id", "duplicate", "queued"}. A repeated sender delivery id (webhook-id, svix-id, x-github-delivery, x-shopify-webhook-id, or a Stripe evt_ id) returns duplicate: true and does not run the script a second time.

The Event object#

@on_event
def handle(event):
    event.json                          # parsed body
    event.headers                       # dict, lowercase keys
    event.header("Stripe-Signature")    # case-insensitive lookup
    event.body                          # raw body string (alias: event.raw_body)
    event.event_id, event.received_at
    event.verified, event.is_verified   # platform verification result
    event.type                          # "incoming" | "schedule" | "manual"
    event.is_incoming, event.is_schedule, event.is_manual
    event.scheduled_time                # schedule runs only

Event is also a dict, so event["event"] works. event.data is the same as event.json; when the body wasn't parsed, event.json tries to parse raw_body.

Signature verification#

On the automation page, under Trigger link, choose the sender (stripe, github, shopify, typeform, standard_webhooks or hmac_sha256), name the secret that holds its signing secret, and turn on Require signature. Your assistant can set the same settings when it builds or changes the automation. Your script sees event.verified ({"provider", "ok"}, or null when no sender is chosen).

With Require signature on, a delivery that fails is answered with 401 invalid_signature, stored as Rejected: signature invalid in the automation's recent events, and never runs. With it off, the delivery runs and event.verified.ok is False. A missing signing secret counts as a failed check. Test events sent from the ClickRuntime dashboard skip verification.

SenderHeader the platform reads
stripestripe-signature (300-second clock tolerance)
githubx-hub-signature-256
shopifyx-shopify-hmac-sha256
typeformtypeform-signature
standard_webhookswebhook-id, webhook-timestamp, webhook-signature (or the svix-* equivalents; 300-second tolerance)
hmac_sha256The Signature header you set, or else the first present of x-signature, x-signature-256, x-webhook-signature, x-hub-signature-256, tally-signature. HMAC-SHA256 of the raw body in the Encoding you set (hex or base64; either when unset). A Prefix such as v1= must lead the value and is removed; when unset, an optional sha256= is removed.

If a sender signs something other than the raw body, such as a timestamp and the body together, verify in the script instead.

In the script#

clickruntime.verify has constant-time helpers. Each returns True or False.

FunctionHeader to pass
verify.stripe(raw_body, header, secret, tolerance=300)stripe-signature
verify.github(raw_body, header, secret)x-hub-signature-256
verify.shopify(raw_body, header, secret)x-shopify-hmac-sha256
verify.typeform(raw_body, header, secret)typeform-signature
verify.standard_webhooks(raw_body, headers, secret, tolerance=300)pass the whole headers dict
verify.generic_hmac_sha256(raw_body, signature, secret, encoding="hex", prefix="")your sender's header; encoding is "hex" or "base64"
from clickruntime import on_event, verify, credentials

@on_event
def handle(event):
    if not verify.stripe(event.body, event.header("stripe-signature"), credentials.secret("STRIPE_WEBHOOK_SECRET")):
        raise ValueError("bad signature")
    ...

Remembered state#

Values and counters that survive between runs, stored per automation. Clear them from the automation page under Advanced with Clear live state.

CallPurpose
state.get(key)Read a value (None if missing)
state.set(key, value)Store any JSON-serializable value
state.delete(key)Remove one key
state.clear_all()Remove every key
state.increment_metric(key, ttl_days, bucket="day", amount=1, initial_value=0)Add to a time-bucketed counter
state.decrement_metric(key, ttl_days, bucket="day", amount=1, initial_value=0)Subtract from a counter
state.query_metric(key, bucket="day", from_time=None, to_time=None)Read counter buckets
state.clear_metric(key)Remove a counter

Keep state small: last-seen ids, prices, timestamps, counters. It is not a database. Keys are 1–255 characters; each value is JSON up to 1 MB. Values stay until deleted or cleared; each counter bucket expires ttl_days (up to 3650) after its bucket time. query_metric returns a list of {"time", "value"} buckets. Never store keys or tokens in state. For patterns (only new items, alert on change, thresholds, cooldowns), see State between runs.

Secrets and connections#

Nothing secret is in the environment. The script asks for each credential when it needs it, with clickruntime.credentials. Values never appear in the script or in chat.

CallReturns
credentials.secret("NAME")The value of a saved secret (UPPER_SNAKE name), for example RESEND_API_KEY
credentials.connection(provider, name)The access token of the connected account name (for example work) of a ready-made app, using its id (starting with cr_), or of an OAuth app your workspace added under Your apps on Connections & secrets, using the app's slug, such as slack, google or hubspot. There is no default account.
Email (built in, always connected)nothing needed; use notify.email_me
  • Declare every secret and connection the script uses in its setup needs. A credential that isn't declared is refused.
  • Call these inside the handler, not at import time: values exist only while the run is running.
  • Access tokens are refreshed when needed. Every value is masked in logs, results and errors as soon as it is read.
  • Errors raise credentials.CredentialError with a safe message and a .code such as not_declared, connection_missing, connection_needs_reauth, secret_missing or run_not_running. A badly formed name raises ValueError.

Google, for example, is connected through your workspace's own Google app, added with the slug google. It has the scopes you gave that app, such as Sheets (spreadsheets), Gmail (gmail.send, gmail.readonly or gmail.modify) and Calendar (calendar.events). If access is revoked, the connection shows Reconnect on Connections & secrets.

from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from clickruntime import credentials

token = credentials.connection("google", "work")
sheets = build("sheets", "v4", credentials=Credentials(token), cache_discovery=False)

Notify#

CallWhat it does
notify.email_me(subject, text)Sends a plain-text email to the automation owner's verified address. No API key needed. Subject up to 200 characters, text up to 100 KB. Up to 10 emails per run and 200 per workspace in any 24 hours.
notify.slack(text, channel=None)Posts as the ClickRuntime app through the workspace's Slack for notifications connection; invite that app to the channel. Scripts never see this token. Always pass channel, like "#ops"; None works only if a default channel was captured when Slack was connected, otherwise the call fails with channel_required. Text up to 40,000 characters. Up to 50 posts per run and 2,000 per workspace per day.

Both raise ValueError on an empty subject or text, and notify.NotifyError when sending fails. The error message names the reason, for example slack_not_connected, channel_required, run_limit, daily_limit, or a Slack refusal such as channel_not_found or not_in_channel.

AI#

clickruntime.ai calls a language model with no API key. Calls count against the workspace's monthly AI tokens, which every plan includes, including the free plan. Input and output tokens are counted separately.

from clickruntime import ai

r = ai.generate("Classify this email as billing, bug or other:\n" + body[:2000], max_output_tokens=20)
label = r.text.strip()

r = ai.generate(text, system="Extract the order.", json_schema={
    "type": "object",
    "properties": {"order_id": {"type": "string"}, "total": {"type": "number"}},
    "required": ["order_id"],
})
order = r.json
  • ai.generate(prompt, *, system=None, max_output_tokens=512, json_schema=None) returns AIResult with text, json (the parsed object when json_schema is given), usage, remaining and finish_reason.
  • prompt is a string, an ai.Text, or a list of them. Text only for now.
  • The model has no tools and can't fetch URLs: fetch the page with http and pass the text in.
  • Prompts and answers are not stored; only token counts are recorded.
  • Failures raise a subclass of ai.AIError (with .code): AINotIncluded, AILimitReached (monthly tokens or this run's calls used up), AIRateLimited, AIInputTooLarge or AIUnavailable.

Checks that find nothing new#

A scheduled script that polls a source for new items should call report_fetched(count) once, right after the fetch:

from clickruntime import on_trigger, report_fetched

@on_trigger
def run(event):
    items = fetch_new_items()
    report_fetched(len(items))
    if not items:
        return
    ...

A scheduled run that reports 0, sends no notification and finishes quickly doesn't count toward monthly runs. It still appears in the run history.

HTTP helper#

clickruntime.http wraps requests with safer defaults:

  • http.get / post / put / patch / delete / head / request(method, url, **kwargs)
  • 20-second default timeout.
  • Retries connection errors and 429/502/503/504 responses for GET, HEAD, OPTIONS, PUT and DELETE (not POST or PATCH), honoring Retry-After.
  • http.session(timeout=20, retries=3, backoff=0.5) for a reusable session.
  • Responses are ordinary requests.Response objects.

Logs#

log.debug / info / warning / error(message, extra=None); log.error(..., exc_info=True) attaches the traceback. print output is captured too. Logs are capped at 4 MB per run (oldest lines dropped first).

Secret values are masked in logs, results and errors, including their base64 and URL-encoded forms and well-known token shapes (for example xoxb-, ghp_, sk_live_, whsec_, AKIA…). Values shorter than 4 characters are not masked. Never print environment variables. To mask a value yourself, for example before returning it, use redact.redact(value) (works on strings, dicts and lists).

Environment variables#

No secrets or tokens are in the environment. The runtime sets these for its own use:

VariableMeaning
CLICKRUNTIME_SECRET_NAMESNames of values that are masked in logs
CLICKRUNTIME_API_URL, CLICKRUNTIME_RUN_TOKENUsed internally by notify, state, credentials and ai. The token is short-lived and masked.

Available packages#

Importable without any install step: requests, httpx, pytz, python-dateutil (dateutil), beautifulsoup4 (bs4), lxml, feedparser, openpyxl, PyYAML (yaml), Jinja2 (jinja2), google-api-python-client (googleapiclient), google-auth (google.auth, google.oauth2), slack_sdk, stripe, twilio, Markdown (markdown), their dependencies, and the Python standard library (except modules that open other kinds of connections or start processes: smtplib, imaplib, poplib, ftplib, telnetlib, socketserver, subprocess, multiprocessing and ctypes). Other PyPI packages can be declared as dependencies of the automation and are installed when it deploys; packages known to be malicious are refused. A script that imports something neither available nor declared is rejected before it can go live.

Limits#

LimitValue
Shortest scheduleEvery 5 minutes
Run time limit180 seconds by default
Memory256 MB by default
Outbound connectionsHTTPS (port 443) to public addresses only
Incoming body size1 MB, UTF-8
Logs per run4 MB
notify.email_me10 per run, 200 per workspace per 24 hours, subject ≤ 200 chars, text ≤ 100 KB
notify.slack50 per run, 2,000 per workspace per day, text ≤ 40,000 chars
ai.generate50 calls per run, 60 per workspace per minute, about 32,000 input tokens per call, output up to 2,048 tokens per call
Temporary files512 MB in /tmp
Monthly runsSet by your plan. Successful and failed runs count; runs that never start because monthly runs are used up do not run the script. Scheduled checks that find nothing new (see report_fetched) don't count.

Run statuses#

StatusMeaning
queuedAccepted, waiting for a runner
runningIn progress
succeededThe handler returned normally
failedThe script raised an error, or the platform could not run it
timed_outThe run hit the time limit
skippedNot run, for example because monthly runs were used up or the automation was paused

Retries#

Runs started by an incoming event are retried when the platform fails or the run times out: up to 3 attempts in total, 30 seconds and then 2 minutes apart. The run keeps one id and shows which attempt it is on. Script errors are not retried unless you turn on Also retry script errors under Retries on the automation page (only do that if the script is safe to run twice for the same event; use event.event_id to skip work already done). You can also lower the total to 1 or 2 attempts. Scheduled and manual runs are not retried; the next scheduled run happens as usual.

Retention#

  • Incoming events are kept for 30 days, then deleted.
  • Run logs are kept for 90 days, then cleared. The run itself (status, times, summary) stays in the run history.
  • Each run's outbound connection records (destination host, address, port and counts) are kept for 90 days to investigate abuse.
  • Remembered state stays until you clear it or delete the automation.

Error codes#

Errors from the ClickRuntime API and from the assistant's tools come back as {"error": "<code>", "message": "<plain text>"}. The codes you are most likely to see:

CodeMeaningWhat to do
not_foundThe automation, run, event or secret doesn't exist in this workspaceCheck the workspace you're signed into
forbiddenYour role doesn't allow this (read-only members can't change automations; only owners and admins manage billing, members, connections and secrets)Ask a workspace owner
setup_incompleteA connection or secret is still missing (the response lists what)Finish the setup checklist on the automation page
script_rejectedThe pre-flight script check refused the script (for example, it imports an unavailable package or prints environment variables)Ask the assistant to change the script
period_exhaustedThe workspace has used its monthly runsWait for the next period or change plan on the Billing page
payment_blocked, admin_blockedRuns are paused for this workspace because of a payment problem or by supportUpdate payment on the Billing page, or contact support
invalid_scheduleThe schedule is malformed or more frequent than every 5 minutesPick a supported schedule
no_scheduleThe automation has no schedule to change (it runs on incoming events)Edit its trigger link settings instead
no_incomingThe automation has no trigger linkUse a schedule, or ask the assistant to add an incoming trigger
not_draft, not_live, not_pausedThe action doesn't fit the automation's current statusRefresh the page and try the action that matches
not_deployedThere is no deployed script to run yetTurn the automation on first
invalid_retryretry.max_attempts must be 1, 2 or 3Choose 1 to 3
invalid_signature_settingsSignature settings don't fit together (for example, Require signature with no sender, or a header or prefix on a sender other than hmac_sha256)Choose a sender and signing secret; set header, encoding and prefix only for hmac_sha256
invalid_signatureAn incoming delivery failed signature verification with Require signature on (HTTP 401)Check the signing secret and sender
too_large, invalid_encodingAn incoming body was over 1 MB or not UTF-8Send a smaller UTF-8 body
slack_not_connectednotify.slack was called without a working Slack for notifications connectionConnect or reconnect Slack for notifications on Connections & secrets
channel_not_found, not_in_channelSlack refused a notify.slack postCheck the channel name and invite the ClickRuntime app to it
channel_requirednotify.slack had no channel and the connection has no defaultPass a channel such as "#ops"
run_limit, daily_limitA notify limit was reached for this run or for the workspace todaySend fewer messages, or combine them
no_verified_emailnotify.email_me found no owner with a verified emailVerify the owner's email address
ai_not_included, ai_limit_reached, ai_run_limitNo AI tokens are left this month, or the run made 50 AI callsWait for the next period, change plan, or make fewer calls
ai_rate_limited, ai_unavailable, ai_failedToo many AI calls at once, or AI is temporarily unavailableRetry later
ai_input_too_largeThe prompt is too long for one callShorten, filter or split it
allowance_unavailableMonthly runs couldn't be checked, so the run was skippedTry again in a minute
not_configuredThat feature isn't set up on this server yet (for example a provider connection)Try again later, or contact support

The assistant's tools can also return: invalid_argument, invalid_setup_needs, secret_in_argument, is_draft, no_script, no_pending_change, confirmation_required, confirmation_missing, confirmation_used, confirmation_expired, confirmation_stale, arm_failed, disarm_failed and unavailable. These are handled in the chat; the assistant explains what to do.