# ClickRuntime script reference

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](#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.

```python
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:

```json
{"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:

```json
{
  "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

```python
@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"` |

```python
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](/docs/state).

## 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.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.

```python
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.

```python
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:

```python
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:

| 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.
