Reference
Script reference
The Python runtime: handlers, payloads, state, connections, notify, HTTP, signature checks, retries, retention, packages, limits and error codes.
View as raw markdownThis 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 nosubprocess,os.system,multiprocessingorctypes. 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.pyand 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.credentialswhile it runs (see Secrets and connections).
Handlers#
A script registers handlers with decorators. At least one of @on_trigger or @on_event is required.
| Decorator | Called when | Argument |
|---|---|---|
@on_trigger | A schedule fires, or someone presses Run now (ClickRuntime dashboard or chat) | Event with type = "schedule" or "manual" |
@on_event | A request arrives at the automation's trigger link | Event with type = "incoming" |
@state_initializer | Optional. Sets up remembered state before the first run, and again after the state is cleared | none |
@state_cleaner | Optional. Runs when state is cleared, just before the keys are deleted. For side effects only: the keys are always deleted | none |
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:
| Kind | Fields | Example description |
|---|---|---|
every_minutes | every_minutes: 5, 6, 10, 12, 15, 20 or 30 | Every 15 minutes |
hourly | minute (0–59) | Every hour at :30 |
daily | time (HH:MM), timezone | Every day at 09:00 (Asia/Kolkata) |
weekdays | time, timezone | Weekdays at 09:00 (Europe/London) |
weekly | weekday (mon…sun), time, timezone | Every Monday at 08:00 |
monthly | day_of_month (1–28, or "last"), time, timezone | Monthly on the 1st at 09:00 |
cron | expression (5-field cron, or 6-field cron with L, W, #), timezone | At 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.
Incoming payloads (trigger links)#
@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}
}
| Field | Meaning |
|---|---|
event | Parsed JSON body, or null if the body isn't JSON. Form-encoded bodies may be parsed into an object. |
headers | All request headers, lowercase keys. Passed unmasked to your script so it can verify signatures; masked everywhere ClickRuntime stores or shows them. |
raw_body | The exact request body as text. Use it for signature checks. |
event_id | ClickRuntime's id for this delivery, stable across retries. Use it to avoid handling the same delivery twice. |
verified | Result 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 platform (recommended)#
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.
| Sender | Header the platform reads |
|---|---|
stripe | stripe-signature (300-second clock tolerance) |
github | x-hub-signature-256 |
shopify | x-shopify-hmac-sha256 |
typeform | typeform-signature |
standard_webhooks | webhook-id, webhook-timestamp, webhook-signature (or the svix-* equivalents; 300-second tolerance) |
hmac_sha256 | The 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.
| Function | Header 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.
| Call | Purpose |
|---|---|
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.
| Call | Returns |
|---|---|
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.CredentialErrorwith a safe message and a.codesuch asnot_declared,connection_missing,connection_needs_reauth,secret_missingorrun_not_running. A badly formed name raisesValueError.
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#
| Call | What 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)returnsAIResultwithtext,json(the parsed object whenjson_schemais given),usage,remainingandfinish_reason.promptis a string, anai.Text, or a list of them. Text only for now.- The model has no tools and can't fetch URLs: fetch the page with
httpand 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,AIInputTooLargeorAIUnavailable.
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.Responseobjects.
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:
| Variable | Meaning |
|---|---|
CLICKRUNTIME_SECRET_NAMES | Names of values that are masked in logs |
CLICKRUNTIME_API_URL, CLICKRUNTIME_RUN_TOKEN | Used 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#
| Limit | Value |
|---|---|
| Shortest schedule | Every 5 minutes |
| Run time limit | 180 seconds by default |
| Memory | 256 MB by default |
| Outbound connections | HTTPS (port 443) to public addresses only |
| Incoming body size | 1 MB, UTF-8 |
| Logs per run | 4 MB |
notify.email_me | 10 per run, 200 per workspace per 24 hours, subject ≤ 200 chars, text ≤ 100 KB |
notify.slack | 50 per run, 2,000 per workspace per day, text ≤ 40,000 chars |
ai.generate | 50 calls per run, 60 per workspace per minute, about 32,000 input tokens per call, output up to 2,048 tokens per call |
| Temporary files | 512 MB in /tmp |
| Monthly runs | Set 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#
| Status | Meaning |
|---|---|
queued | Accepted, waiting for a runner |
running | In progress |
succeeded | The handler returned normally |
failed | The script raised an error, or the platform could not run it |
timed_out | The run hit the time limit |
skipped | Not 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:
| Code | Meaning | What to do |
|---|---|---|
not_found | The automation, run, event or secret doesn't exist in this workspace | Check the workspace you're signed into |
forbidden | Your 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_incomplete | A connection or secret is still missing (the response lists what) | Finish the setup checklist on the automation page |
script_rejected | The 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_exhausted | The workspace has used its monthly runs | Wait for the next period or change plan on the Billing page |
payment_blocked, admin_blocked | Runs are paused for this workspace because of a payment problem or by support | Update payment on the Billing page, or contact support |
invalid_schedule | The schedule is malformed or more frequent than every 5 minutes | Pick a supported schedule |
no_schedule | The automation has no schedule to change (it runs on incoming events) | Edit its trigger link settings instead |
no_incoming | The automation has no trigger link | Use a schedule, or ask the assistant to add an incoming trigger |
not_draft, not_live, not_paused | The action doesn't fit the automation's current status | Refresh the page and try the action that matches |
not_deployed | There is no deployed script to run yet | Turn the automation on first |
invalid_retry | retry.max_attempts must be 1, 2 or 3 | Choose 1 to 3 |
invalid_signature_settings | Signature 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_signature | An incoming delivery failed signature verification with Require signature on (HTTP 401) | Check the signing secret and sender |
too_large, invalid_encoding | An incoming body was over 1 MB or not UTF-8 | Send a smaller UTF-8 body |
slack_not_connected | notify.slack was called without a working Slack for notifications connection | Connect or reconnect Slack for notifications on Connections & secrets |
channel_not_found, not_in_channel | Slack refused a notify.slack post | Check the channel name and invite the ClickRuntime app to it |
channel_required | notify.slack had no channel and the connection has no default | Pass a channel such as "#ops" |
run_limit, daily_limit | A notify limit was reached for this run or for the workspace today | Send fewer messages, or combine them |
no_verified_email | notify.email_me found no owner with a verified email | Verify the owner's email address |
ai_not_included, ai_limit_reached, ai_run_limit | No AI tokens are left this month, or the run made 50 AI calls | Wait for the next period, change plan, or make fewer calls |
ai_rate_limited, ai_unavailable, ai_failed | Too many AI calls at once, or AI is temporarily unavailable | Retry later |
ai_input_too_large | The prompt is too long for one call | Shorten, filter or split it |
allowance_unavailable | Monthly runs couldn't be checked, so the run was skipped | Try again in a minute |
not_configured | That 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.