Functions and the host SDK

A function is a pure callable: a named piece of real code, Python or Go, carried inline on its manifest. It has no subscription and no schedule of its own. What fires it is a separate trigger. When it runs it reads its input, computes, and returns a list of effects the engine applies through the ordinary write path under the function's own actor, plus an output value for whoever called it. Writes are never blocked: a function is a subscriber, not a gatekeeper. One identity, four ways in: a trigger delivery, another function's host call, the HTTP call API, or a manual per-trigger run.

Functions ship inside an extension, beside the entity types they read and write. Here is one shaped like the URL harvester's, which turns a freshly minted page record into fetched markdown:

group: core.teild.dev
type: function
metadata:
  id: fetchpage.web.bundles.teild.dev
data:
  group: web.bundles.teild.dev
  description: Fetch one pending page as markdown and mark it fetched.
  runtime: python
  capabilities:
    emit:
      - page.web.bundles.teild.dev
  source: |
    def main(input, host):
        env = input.get("envelope") or {}
        page = env.get("entity") or {}
        props = page.get("properties") or {}
        url = props.get("url", "")
        title = url.rstrip("/").split("/")[-1].replace("-", " ") or url
        markdown = "# " + title + "\n\nfetched from " + url
        return {
            "effects": [{
                "action": "patch",
                "type": "page.web.bundles.teild.dev", "id": page.get("id"),
                "properties": {"title": title, "content": markdown,
                               "fetch": "fetched"},
            }],
            "output": {"page": page.get("id")},
        }

The manifest

data carries the whole callable:

capabilities:
  emit:                            # required, non-empty: the write allowlist
    - task.tasks.teild.dev
  reads:                           # the host-read allowlist and budget
    types:
      - person.people.teild.dev
    budgets:
      calls: 16
      rows: 500
  call:                            # host-call allowlist (registered functions)
    - normalize.example.com
  network:                         # declared for review; not yet enforced
    - api.example.com
  mutations:                       # gates the merge / split effects
    - merge

emit is required and lists the type identities the effects may address: this function may write those types and nothing else. reads is the host-read allowlist plus a budget (defaults 16 calls / 500 rows). call is the host-call allowlist; every target must be a registered function. network is declared for review, not yet enforced on the same-host runner. mutations gates the merge and split effects, which are refused without it.

The body's entrypoint is main(input, host) in Python (Main(in, host) in Go), and it returns {effects, output}. input names the mode that woke the body (trigger, schedule, webhook, manual or call) beside an idempotencyKey and a causalDepth, then carries that mode's payload: a delivery puts the envelope under input["envelope"], while a direct call puts the caller's own JSON under input["args"].

The delivery envelope

An entity-triggered delivery arrives as input["envelope"], three keys:

change:
  seq: 412
  op: update
  group: github.bundles.teild.dev
  type: account
  id: gh-acct-1
  actor: owner
  changed:
    - tokenStatus
entity:
  id: gh-acct-1
  group: github.bundles.teild.dev
  type: account
  properties:
    tokenStatus: connected
tenant:
  owner: geoah

change says what moved (op is create, update or delete, and changed names the properties when the payload carries them). entity is the row's state now, not the old value, and is null after a delete. It also carries an edges map, relation to targets, where each target names its type the same way entity does, plus that target's title. A schedule or webhook delivery has no changelog row underneath it, so its envelope carries fire (the fire's id and at) and tenant in place of change and entity.

The envelope splits identity, and the SDK does not. In change, in entity and in every edge target, type is the bare local name and group sits beside it. Everywhere else, host.entities., host.effects. and an explicit effects list, type is the full identity, <name>.<group>. The two conventions never mix.

A bare local name identifies nothing on its own. Every extension names its settings type config and its connection type account; github and linear both ship an issue. So a body that wants to know whether a delivery is about its own type recomposes the identity and compares that:

ACCOUNT_TYPE = "account.github.bundles.teild.dev"

def main(input, host):
    entity = (input.get("envelope") or {}).get("entity") or {}
    ident = "%s.%s" % (entity.get("type") or "", entity.get("group") or "")
    if ident != ACCOUNT_TYPE:
        return {"output": {"skipped": True}}
    account = host.entities.get(ACCOUNT_TYPE, entity.get("id"))
    return {"output": {"tokenStatus": (account or {}).get("properties", {}).get("tokenStatus")}}

Both shortcuts are silent, not loud. Comparing entity["type"] against a full identity never matches, so the branch simply never runs. Comparing it against a bare "account" matches any extension's account.

Handing a bare name back to the SDK fails differently in each direction. host.effects.* refuses it outright: the builder checks the name.group shape locally, so the mistake is a clear body error on the first delivery. host.entities.get does not check, and passes the name to the engine, which resolves it only while exactly one group declares it. That call works in development against one installed extension and starts failing the day a second extension declares the same local name, which is the worse failure of the two. Address reads by the full identity, always.

The one place the split shape is what you want is an edge target, which is written {group, type, id} with the bare name. That makes an envelope's change a straight copy into a reference:

source = {"group": change["group"], "type": change["type"], "id": change["id"]}

How the body runs

A function's source is prepared at install, synchronously, and a body that cannot run fails the install rather than the first delivery. Python source registers into a shared runner host; Go source compiles to a cached binary the runner supervises. Both speak the same JSON-lines protocol to that runner, a child process of the substrate, never in-process user code.

A dependency-free Python function and every Go function take the fast path. A Python body that needs libraries declares them with a PEP 723 inline metadata block, and the runner provisions a cached virtual environment with uv at registration:

# /// script
# dependencies = ["google-api-python-client"]
# requires-python = ">=3.11"
# ///

A body declaring dependencies runs as one isolated process per installation, keyed by tenant plus function plus content hash, never on the multi-tenant shared host. This is crash and placement isolation, not a security boundary: every child runs as the same container user, so it defends against accident and collision, not against a hostile same-uid body.

Shared modules

A bundle may ship library modules its functions import (modules: on the bundle manifest, filename to inline source, at most 256 KiB each), so a provider's functions dedupe a shared HTTP client or normalizers instead of every body re-implementing them. .py files land on a per-installation module path (appended after the interpreter boots, so no sitecustomize.py can auto-run and no json.py can shadow the stdlib); .go files vendor into the Go build as teildfn.local/lib. Modules are inline sources on the bundle document, not closure members, so they never appear in installs:, and changing one re-registers or rebuilds the function exactly like changing the body.

Effects

Effects are the ordinary seven mutations, applied through the write path in the same transaction as the delivery's cursor advance, every one held to capabilities.emit by the type it names:

A put or patch addressed to a former id resolves onto the canonical winner instead of parking. Ids are required on every action but split (whose address is the merge record), and because a function composes the ids of what it writes, put/patch/delete/link/unlink replays are idempotent by construction.

The SDK

The runner passes one host object to every body. It carries the same namespaced surface in both runtimes, concept for concept; only the spelling follows each language, so a multi-field call takes Python keyword arguments where Go takes an option struct (host.effects.put(type=…, id=…) against host.Effects.Put(teildfn.PutEffect{Type: …, ID: …})) while the fixed-arity ones stay positional in Go (Get, Delete, Merge, Split). One name differs between the two: Python's order is Go's OrderBy. Go also adds typed read results Python has no need of.

Reads. host.entities.get(type, id), host.entities.list(types, where?, first?, after?, order?, with_edges?), host.entities.search(q, types, k?, mode?) (mode is lexical, semantic or hybrid, and defaults to hybrid), and host.functions.call(function, input). get addresses one entity by its full identity, the (type, id) pair; a bare id names nothing and the frame is refused. Reads see committed state, never this delivery's own staged effects, so a local overlay can never lie. A forbidden type answers exactly like an absent id (same nil shape, same budget charge), so a disallowed get is never an existence or type oracle. Reads are held to capabilities.reads: with no reads: block the allowlist is empty, so every list and search is refused and every get answers absent. Calls are charged before they run, first and k clamp to the remaining row budget, and returned rows charge on top. In Go the typed read returns a *ReadEntity whose Version is an int64, so the CAS idiom IfVersion: teildfn.Version(e.Version) is writable straight off a read; in Python host.version(entity) returns that integer.

Writes, the buffered-effects builder. host.effects.put / patch / delete / link / unlink / merge / split(...) each append a staged effect to a write-only buffer and return a staged-effect handle, never an entity and never a value to inspect. There is no flush(): the buffer is the return. The builder validates shape locally against the engine's own alphabets (a URL-safe id, a name.group type identity, a camelCase relation, a well-formed edge target, a boolean ifAbsent, a non-negative integer ifVersion, no self-merge) and snapshot-copies caller maps through JSON, so a mistake is a clear body error rather than an engine park. The action needs no checking: it is the method you called. The engine stays authoritative for the emit ceiling and type admission.

One mode per invocation. A body either returns an explicit effects list or stages on the builder, never both. The two apply orders are unrelated (returned first, then staged) and could reverse writes or self-conflict under the version check, so a result carrying explicit effects while the buffer is non-empty is refused outright, naming both counts. The example above returns an explicit list; a body using the builder returns no effects key.

Deterministic ids. host.ids.external(provider, account, external_id) and host.ids.url(url) produce stable, URL-safe, hash-backed ids, byte-identical across the two runtimes. A deterministic id is a per-type identity: the same derived id used for two different types names two independent entities, so the writer names the type on every put and get that uses it. ids.url hashes the exact URL with only surrounding-whitespace trimming and no canonicalization, so distinct spellings are distinct ids by design; a structural canonicalizer, when needed, is a separate named helper.

Paging. host.page.resume() returns the opaque cursor the previous page returned (absent on a fresh delivery), and host.page.more(cursor) builds the continuation a paged body returns as its more. This is the first-class wrapper over the paged-checkpoint protocol, so a body syncing a provider one page per invocation stops hand-building cursor dicts.

Configuration and connected accounts. host.config() (host.Config() in Go) returns the callable's resolved configuration: the owning bundle group, the extension's single bundleconfig entity under config, and every Connection the group declares under accounts, each flattened to its id, type, and stored properties. For an OAuth extension the host resolves each account's credential itself and hands the body a live token on the account entry, or a tokenError string when the grant is dead, so one broken account never parks the whole delivery. The OAuth facility's own secrets, the config's clientSecret and an account's tokenRef, are never injected: a body gets the resolved token and nothing it could exfiltrate a credential with. Every injected secret value is scrubbed out of whatever crosses back over the runner boundary.

Logging. host.log(msg) in Python, host.Logf(format, args...) in Go, records a line on the invocation's run record. Lines are truncated at 4096 characters and capped at 200 per invocation, with the remainder counted rather than kept, so a chatty body cannot flood the record.

Frames and ceilings. One message between a body and the runner is a single JSON line capped at 8 MiB, in both runtimes. A response that would exceed it is replaced by a clear error rather than a truncated frame, which is the real reason a body that walks a provider pages instead of returning everything at once. The read budget defaults to 16 calls and 500 rows, and a manifest may raise it to at most 1000 calls and 10000 rows.

Host call

host.functions.call(function, input) runs another function to completion inside the caller's invocation. The runner refuses a target outside the caller's capabilities.call and charges the call budget before executing; the engine refuses a target already on the call stack (direct and mutual recursion both) and one that would exceed the causal-depth cap. The callee gets its own fresh read budgets and its own timeout (bounded by the caller's remaining deadline), and its output returns to the calling body. Its effects do not apply on the spot: they accumulate, decoded against the callee's own capability envelope, and land in the caller's delivery transaction, sub-call effects first in call order, all under the delivery's actor. One delivery is one transaction, so a caller that fails after a sub-call rolls the sub-call's writes back with it.

Triggers

A function does not watch anything. A trigger is a data entity in the shipped automation.teild.dev group, console-editable and ssctl apply-able like any other, that binds one source to one callable and owns the delivery. Here is the trigger that drives the function above:

group: automation.teild.dev
type: trigger
metadata:
  id: web-fetch-on-page
data:
  properties:
    enabled: true
    source:
      entity:
        types:
          - page.web.bundles.teild.dev
        ops:                     # create | update | delete
          - create
        when: >-
          entity != null && entity.properties.fetch == "pending"
    callable:                    # a reference: the type names the kind
      type: function
      id: fetchpage.web.bundles.teild.dev

source takes exactly one arm:

callable is a reference naming the function or agent to run, and its type is the kind (function or agent); enabled defaults to true, and setting it false stops delivery without losing the cursor's position. Every trigger write is admitted: the guard must compile, the recurrence and timezone must parse, and the callable must resolve to a registered callable of its kind.

The when: guard is the one place CEL survives. It is a boolean over three read-only bindings, change, entity (null after a delete), and tenant. There is deliberately no clock and no way to fetch other entities: a guard is a cheap filter, not the computation. The computation is the function body.

What makes this safe to run unattended

Driving triggers

Delivery bookkeeping lives on the trigger, not on the function, and the verbs that drive it live at the trigger resource, under automation.teild.dev/triggers/… (a resource's verbs live at the resource):

replay answers the cursor it set; run, wake and retry answer {"ran": n}, the number of deliveries that applied effects.

ssctl function call <name> --input <json> invokes one function directly, applies its effects under the function's actor, and prints the effect count beside the output. Over HTTP that is POST /api/v1/core.teild.dev/functions/{name}/call with {"input": …}, answering {"output": …, "effects": n}; what you send arrives at the body as input["args"]. The trigger rows are ordinary entities, so get / apply / delete edit them like anything else. The core.teild.dev/triggers/… paths from before the freeze keep answering for one release, each carrying a Warning header naming the automation.teild.dev replacement.

Next: agents, callables whose body is an LLM loop.