# Scripts

A script is a small, deterministic Python program that the platform runs in a
sandbox. It has no network and no imports beyond an allowlist; everything it
may touch goes through one object, `ctx`. A script belongs to exactly one skill
(`scripts.create` takes `skill_id`). Every version is immutable and every live
run records its write effects.

You write only code. `scripts.versions.create` takes the source, a one-line
purpose and optionally a description, input and output JSON schemas and
limits. The platform derives from the `ctx` calls what the script reads, writes
and calls (record types, folders, integrations and operations, files, log) and
stores that as the version's manifest. There is no approval of a script by
itself: a skill decides where it runs.

## Flow

1. `scripts.create` with `skill_id`.
2. `scripts.versions.create` with source and purpose. Fix static-check
   findings (`static_check.findings`) and `readiness.violations` first; each
   fix is a new version.
3. `scripts.testcases.set` with real example items. Its name is shown to
   users as the title of every test run, so use a short readable label such as
   "Order #41626 updated", not a slug.
4. `scripts.testcases.run` with the `testcase_id` and `space_id`. This is a dry
   run: reads are real, writes are only recorded as simulated effects, no
   confirmation is needed. `scripts.runs.dry_run` does the same without a
   test case.
5. Read `scripts.runs.get`, `scripts.runs.effects` and `scripts.runs.output`,
   then `scripts.testcases.accept` with that run.
6. Pin the version in the skill: `skills.draft.save` with the script in
   `scripts[]` (`scriptId`, explicit `version`, `alias`) and the instructions
   mentioning it as `@script:<alias>`, then `skills.save`. Publishing lets the
   pinned version run live as the tool `run_script__<alias>`.

`scripts.runs.start` (live) through the API works only for a version the
published skill pins. With `settings.agentScriptsEnabled` the skill agent can
also write and run the skill's scripts itself during a skill run
(`script_write`, `script_run`); start a rehearsal with `skills.runs.start` and
`dry_run` to see what it writes; `skills.runs.get` lists `script_versions` and
`script_runs`.

## The contract

The source defines one entry point, `main`, and may add helper functions and
classes (at most 12 top-level functions in total):

```python
def main(ctx, items, params):
    return {"items": [...], "exceptions": [...], "summary": {...}}
```

`items` are the input items, `params` the parameters. `exceptions` holds the
items the script could not process; the run still succeeds. Returned
exceptions and those recorded with `ctx.exception` are merged; each needs a
code and a message.

Schemas: `input.items`, `input.parameters`, `output.items` and
`output.summary` are optional JSON schemas. Left out, items and parameters are
open objects. A schema with declared properties is closed: every property
declared, no additional ones.

## The ctx API

- `ctx.now`: the frozen run timestamp (datetime). The only clock.
- `ctx.random`: a seeded random source. The only randomness.
- `ctx.new_id()`: a deterministic id derived from the run seed.
- `ctx.params`: the parameters, same content as `params`.
- `ctx.log(message, **fields)`: one structured log line (at most 500 lines
  and 2000 characters per message).
- `ctx.exception(item_index, code, message, data=None)`: record an exception
  for one input item instead of failing the run.
- `ctx.records.query("<record type key>", filter=None, fields=None, limit=200,
  cursor=None)`: one page of records; pass the returned cursor to continue.
- `ctx.records.get("<record type key>", record_id)`: one record.
- `ctx.records.upsert("<record type key>", rows, merge_on=["<field>", ...],
  restore_archived=False)`: write rows; `merge_on` is a literal list of one to
  three fields; no match creates, one match updates, several matches fail. At
  most 1000 rows per call. Returns created, updated, restored, skipped, items
  and errors; it never raises for a bad row. Rows are written in batches of
  200, and one row error discards its whole batch, so check errors and report
  them (`ctx.exception` or raise). A row whose merge key matches an archived
  record, as after a reverted run, is such an error unless
  `restore_archived=True`; scripts that refresh their own rows set it.
- `ctx.documents.list(folder_id=None, cursor=None)`: documents, paged: of a
  literal folder id, or without folder the documents of the run's scope.
- `ctx.documents.read(document_id, offset=0, limit=20000)`: a slice of the
  document's extracted text.
- `ctx.documents.copy_to_workspace(document_id, path)`: copy the original of a
  CSV, JSON, XML or TXT document into the working directory.
- `ctx.integrations.call("<integration key>", "<resource.operation>",
  arguments)`: one call of an operation the skill enables; the first argument
  is the key of an integration of the skill.
- `ctx.files.open(path, mode="r")`: a normal Python file object in the working
  directory of the run (modes r, rb, w, wb, a, ab); `ctx.files.list()` and
  `ctx.files.remove(path)`. Paths are relative, no "..". The working directory
  belongs to the skill run and is deleted when it ends; outside a skill run
  each script run gets its own. A run that reads files cannot be replayed.

## Rules

Sandbox
- Importable modules: abc, base64, binascii, bisect, collections, copy, csv, dataclasses, datetime, decimal, defusedxml, enum, fractions, functools, hashlib, heapq, hmac, io, itertools, json, math, numbers, re, statistics, string, textwrap, typing, unicodedata, zoneinfo. Nothing else.
- Refused as nondeterministic: calendar, random, secrets, time, uuid. Use `ctx.now`,
  `ctx.random` and `ctx.new_id()` instead. `os`, `sys`, `pathlib`, `open`,
  `subprocess`, `socket`, `urllib` and the rest are not available at all.

Limits
- Size: at most 400 source lines, 150 lines in
  `main`, 12 top-level functions and 64 KB.
- Run limits are optional, each within the platform ceiling: `wall_seconds`
  (default 120, max 600), `cpu_seconds` (30/120), `memory_mb` (256/512),
  `max_capability_calls` (300/1000), `max_effects` (2000/5000),
  `max_items_in` and `max_items_out` (2000/5000), `max_input_bytes` and
  `max_output_bytes` (8 MiB/16 MiB).

Determinism
- The same input must produce the same effects. Derive keys and ids from the
  input data (or `ctx.new_id()`), never from the execution order or the
  current time. Iterate sets only through `sorted(...)`.
- The only clock is `ctx.now`. The module `time` is refused as a whole, so
  `time.time()` and `time.strftime()` are not available. The attributes
  fromtimestamp, now, today, utcfromtimestamp, utcnow are refused on anything but `ctx` (for example `datetime.now()`, `date.today()`,
  `datetime.fromtimestamp()`). Formatting and parsing are fine:
  `ctx.now.strftime("%Y-%m-%d")`, `datetime.strptime(...)`,
  `datetime.fromisoformat(...)` and `timedelta` arithmetic are allowed.

Rights and scope come from the skill
- Everything a script uses must be visible as literals in the code: record
  type keys, integration keys, operations, folder ids and `merge_on`.
- The skill's rights are the upper bound: a script may only use the
  integrations and operations the skill enables, the record types of its
  record scope and the documents of the run's scope. `readiness.violations` of
  a version lists what the skill would refuse.

One script, one purpose
- A script is a building block: one input shape, one output shape, one job.
  Chain several scripts instead of writing one large script.
- `scripts.runs.dry_run`, `scripts.runs.start` and the skill tools take
  `input_from_run_id` instead of items: the output items of that finished,
  successful run become the input (same Space; inside a skill run only runs of
  the same skill run; a live run only takes the output of a live run). Inside
  a skill run the tools also take `input_file`, a JSONL file of the working
  directory. Bulk data never travels through an agent.

## Errors and fixes

Static check (a version with an error finding cannot run; fix the source and
create a new version):

- S-00: the source does not parse. Fix the syntax error at the given line.
- S-01: import outside the allowlist, relative import or star import.
- S-02: `main(ctx, items, params)` is missing, duplicated or has other
  parameters, or the top level holds more than imports, constant assignments,
  functions and classes.
- S-03: blocked builtin (`eval`, `exec`, `open`, `getattr`, `type` and the
  like), dunder attribute or name mangling.
- S-04: hidden nondeterminism: a clock call or a nondeterministic or
  unavailable module.
- S-05: async, await, async for/with or async comprehensions; a decorator
  outside the allowlist; metaclasses, class keywords, special methods or
  `except*`.
- S-06: recursion, or a `while` loop without a bound that depends on a
  variable.
- S-07: size: more than 400 source lines,
  150 lines in `main`, 12 top-level functions, or
  more than 64 KB or NUL bytes.
- S-08: `ctx` reassigned, aliased or passed other than positionally under the
  name `ctx`; a record type key, integration key, operation, folder id or
  `merge_on` that is not a literal.
- S-09: an argument the platform sets itself (space, tenant, idempotency key
  and the like) passed by the script.
- S-10: a format template with attribute or index fields, or an extracted
  format method.
- S-11 (warning): iterating a set without `sorted(...)`.
- S-12: identifiers and string literals must be ASCII without invisible or
  bidirectional characters.

Readiness and pin issues:

- `script_target_out_of_scope`: a record type the script reads or writes is
  outside the skill's record scope. Add it to the skill's record scope in the
  skill draft, or change the code.
- `script_operation_not_allowed`: the skill does not enable an integration
  operation the script calls. Enable it in the skill draft, or change the code.
- `script_slot_unknown`: the integration key in the code is not an integration
  of the skill, or its provider changed. Use the skill's integration key and
  write a new version.
- `script_dry_run_missing`: the pinned version has no successful dry run. Run
  a test case (`scripts.testcases.run`) or `scripts.runs.dry_run` for exactly
  that version.
- `script_version_outdated`: a newer version exists than the pinned one. Pin
  the newer version after it passed a dry run, or keep the pin deliberately.
- `script_version_not_published`: a live run needs a version the published
  skill pins. Dry-run it instead, or pin and publish.

## Example

```python
from decimal import Decimal


def to_row(item, stamp):
    return {
        "order_id": str(item["id"]),
        "total": str(Decimal(str(item["total"])).quantize(Decimal("0.01"))),
        "synced_on": stamp,
    }


def main(ctx, items, params):
    stamp = ctx.now.strftime("%Y-%m-%d")
    rows = []
    for index, item in enumerate(items):
        if "id" not in item or "total" not in item:
            ctx.exception(index, "missing_field", "id and total are required")
            continue
        rows.append(to_row(item, stamp))
    result = ctx.records.upsert("orders", rows, merge_on=["order_id"])
    for error in result["errors"]:
        ctx.log("upsert error", error=error)
    return {
        "items": rows,
        "exceptions": [],
        "summary": {"created": result["created"], "updated": result["updated"]},
    }
```
