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:
descriptionis model-facing and required: the function is its own tool card wherever it appears as a callable.runtimeispythonorgo.sourceis the inline body (bounded, at most 256 KiB).timeoutMsbounds one invocation's wall clock, host calls included (default 5000, max 60000).- Optional
input:andoutput:shape schemas check a caller's arguments before the body runs and the returned value after. The schema dialect is deliberately tiny:{type: object|array|string|number|boolean|any, properties, items, required, description}, nothing else. An object schema with apropertieskey refuses undeclared keys (key presence closes the object); an object schema with nopropertieskey is the open object. Trigger deliveries ignore both, because the envelope is the input. capabilitiesis the capability envelope, the whole security boundary:
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:
put{action: put, type, id, ifAbsent?, ifVersion?, properties?, edges?}.ifAbsent: trueis create-only: any existing row, live or tombstoned, is a no-op, so a minting function never resets state a later stage owns.ifAbsentmust be a boolean, and it cannot combine withifVersionon one put (the version check would be silently dropped).ifVersion(put and patch) is the optimistic-concurrency precondition: an integer the write applies against only if the stored version equals it (a non-existent entity is version 0), else the whole delivery failsconflict. It is the safe read-then-conditional-write primitive.patch{action: patch, type, id, properties}. A state value among the properties is a transition; re-asserting the current state is a no-op.delete{action: delete, type, id}tombstones.link/unlink{action, type, id, rel, to}, whereidis the source entity andtoa bare id or{group, type, id}reference;linkalso takes the edge's ownproperties. Emit gates by the source type.merge{action: merge, type, id, loser}(idis the winner) andsplit{action: split, type, merge}, both refused unlesscapabilities.mutationsgrants them; the*requestentities stay the polite default for agent chains.
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:
- An
entityarm subscribes to the changelog: the trigger owns a cursor and, for every committed change to a matched type and op whosewhen:guard passes, delivers the callable the entity's current state. Each entry intypes:is a full type identity,.<group>for a whole group, orfor everything; the wildcard is a dotted-suffix match, so*.teild.devalso catchesperson.people.teild.dev.ops:omitted means all three. Optionalcoalesce: truekeeps only the latest matched change per entity in a batch. - A
schedulearm fires the callable on an RRULErecurrence(with atimezoneand optionalstartsAt), with no changelog row underneath and no guard. - A
webhookarm has no scan of its own; an authenticated wake delivers one fire.
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
- Idempotent by construction. A function composes its own ids,
putupserts, and identical writes are suppressed. Replaying a trigger over the whole changelog is a no-op where it already ran. The dispatcher advances an entity trigger's cursor in the same transaction as the effects, so substrate-side consequences are effectively-once; external consumers get an at-least-once floor, made safe by the same id composition. - No loops. Every function-authored write records the change that caused it, and a trigger never delivers writes carrying its own callable's actor. A causal chain deeper than the engine's cap (16) parks instead of spinning.
- No wedging. A delivery that keeps failing is parked (3 attempts with backoff; a deterministic trip like an allowlist or budget violation parks on the first) and the trigger's cursor moves on. A false
whenis a skip, not a failure.
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):
GET …/triggers/statusis the one collection-level verb: every trigger's kind, callable, cursor, head, lag, last fire and parked count in a single answer. There is no per-triggerstatus.POST …/triggers/{id}/replaytakes{"from": seq}and resets an entity-sourced trigger's cursor for a retrospective run.POST …/triggers/{id}/runtakes{"type": …, "id": …}, both required, and synthesizes one delivery of that entity's current state (guard applied, source filter not, cursor untouched).POST …/triggers/{id}/wakescans now: a webhook fires once, an entity trigger drains its backlog, a schedule checks its due occurrence.GET …/triggers/{id}/parkedlists the deliveries the trigger gave up on, andPOST …/triggers/{id}/parked/{failureId}/retryre-runs one.
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.