Targets and Interceptors#
A capture target is a directory under the server root holding one Python
module that marks a procedure with @cgserver.target. The directory's path
relative to the root is the target's name, so directories double as groups:
Plants/Tulip and Plants/Sunflower appear together under Plants in the
app and the panel. The module's file name is free (the management panel names
a new one after its directory); everything else about a target lives in the
same directory:
my-deployment/
├── config.toml
├── .cg-scratch/ ← the recipe scratch every interceptor's memory lives in
└── PlantJournal/
├── plant_journal.py ← the target module (you write this)
├── .history/ ← snapshots of validated saves
└── sessions/ ← schema-defined directories of captured files
├── 00063B40E29D696A/
│ ├── photo.heic
│ └── notes.json
└── ...
The schema fixes each file's stem; its extension is whichever of the scalar's
accepted ones the client uploaded (photo.heic from a phone, photo.jpeg
from a test). A target has no separate manifest file. Its on-disk layout is its
schema, the cg.Struct you declare, and the schema
travels with the procedure definition the app downloads. For a guided build of
a complete target, read From Zero to a Scheduled Target;
this page is the reference.
The main procedure#
The module declares a schema, records a procedure against it with
@cg.procedure, and marks that procedure as the target's
main procedure with @cgserver.target. There is exactly one per module, and
one module per directory:
"""A target that captures a daily photo and a short note."""
import capturegraph as cg
from server import authoring as cgserver
class Session(cg.Struct):
photo: cg.Image
notes: cg.String
_metadata: cg.Metadata
class PlantJournal(cg.Struct):
sessions: cg.Map[cg.Date, Session]
_saw_instructions: cg.Bool
_metadata: cg.Metadata
@cgserver.target
@cg.procedure(PlantJournal)
def plant_journal(root):
root._saw_instructions |= cg.ShowInstructions(
text="Photograph the plant from the marked spot."
)
session = root.sessions[cg.CaptureTime()]
session.photo &= cg.CaptureImage(label="Photograph the plant")
session.notes &= cg.UserInputString(label="Notes")
@cgserver.target marks the recorded procedure and returns it unchanged. The
server reads its {schema, root, nodes} wire form straight off the function
and serves it at GET /api/v1/targets/{target}/definition; the schema is
embedded in that response, so it doubles as the data layout the app, panel, and
sync tool walk.
Drop the directory into the server root and it is hosted within
target_refresh seconds, no restart. The server re-executes the module as
soon as the file changes on disk (it stats the file per request), so saving
an edit is deploying it: a participant pulling to refresh sees the new
procedure within seconds.
Recording against the schema#
Inside the body, root is a Path into the schema. You
navigate it by field, index, and key, and cache results into positions with
two operators:
path &= value— overwrite: capture this fresh in every session.path |= value— skip if it already exists: configuration, references, and once-per-target values.
cg.do(step) records a side-effect-only step (a metronome, an assertion).
Instructions are the exception: cg.ShowInstructions returns the user's
acknowledgement, so cache it with |= into a hidden cg.Bool field (as above)
to show it once instead of every session.
Control flow uses context managers: with cg.when(cond):,
with cg.optional():, and with cg.while_repeat(cond, array_path) as elem:. See
the DSL guide for the full recorder surface.
Keep module import cheap
An unchanged module is served from cache, but anything at module scope
still runs on every edit, at validation, and at server startup. Defer
expensive work into interceptor bodies, and memoize it with a
cg.pure_function so the recipe cache serves the result after the first
run.
Interceptors#
An interceptor fills a schema position dynamically: instead of
reading that position's file from disk, the server computes it on every
download, for the requesting user. This is the hook that turns a passive file
host into a capture coordinator. The positions the iOS app polls on its own are
s._metadata._next_notification (a cg.Time, the next capture time),
s._metadata._location (a cg.Location, the next capture point), and each
session's _metadata._thumbnail (a cg.Thumbnail, for its gallery).
Hidden (_-prefixed) schema fields map to dotfiles on disk, so
_metadata._next_notification is served as .metadata/.next_notification.
Declaring one: intercept#
@cgserver.intercept(Schema, /, select, *, skip_if_exists=False) names a
position with a selector, a plain function of the schema that walks from
its root to the scalar the function fills:
@cgserver.intercept(PlantJournal, lambda s: s._metadata._next_notification)
def notification(profile) -> cg.Time:
return cg.Time(next_capture_time)
The selector runs once at import against a stand-in that records the fields it walks, so a field the schema does not have fails the target load there, never as a silent missing file at serve time. Three rules govern the walk:
- Crossing a container is explicit. A
MaporArrayis a stop, not a step:lambda s: s.sessionsselects the map itself, andlambda s: s.sessions.for_each()every session in it. Solambda s: s.sessions.for_each()._metadata._thumbnailclaims the thumbnail of every session, including the ones whose keys nobody can name ahead of time; the pattern issessions/*/.metadata/.thumbnail. There is no way to claim one key. - A field of a container needs the
for_each().lambda s: s.sessions.photofails at import naming the fix,s.sessions.for_each().photo, and so does afor_each()anywhere but at a container.for_eachis a reserved name in a selector, as_cg_selectionis: a schema field cannot be called either. - Indexing is a load error.
lambda s: s.sessions["…"]fails at import with a selector cannot index.
The selector must end at a scalar: the root (lambda s: s), a struct, or a
container (lambda s: s.sessions) is rejected at decoration with the type it
reached and, for a container, the for_each() that reaches its elements. The
same selectors declare page positions, which may end
anywhere.
@cgserver.intercept(
PlantJournal,
lambda s: s.sessions.for_each()._metadata._thumbnail,
skip_if_exists=True,
)
def thumbnail(node: Session) -> cg.Thumbnail:
return render(node.photo)
A request is matched against the declared patterns most specific first, so an
intercept at an exact position wins over one crossing a container that also
covers it. Two functions claiming one pattern, or a declaration made against a
schema other than the target's, are caught when the module loads: the target
still serves captures, but with every interceptor disabled and the reason
in its status.
skip_if_exists chooses between two modes:
| Mode | Reads | Client writes to the position |
|---|---|---|
Shadowing (False, default) |
The function always answers. | Refused with 409. |
Disk-first (True) |
A file on disk is served as is; the function only fills the absent case. | Land, and serve thereafter. |
Function parameters#
An interceptor declares any subset of three parameter names, in any order; the server passes exactly the ones the signature asks for, as keyword arguments:
| Parameter | Type | Description |
|---|---|---|
profile |
cgserver.RequestProfile |
The identified caller: profile.user_id is the requesting user (a str; different users can get different answers), and profile.location their position (cg.Location \| None, present only when they opted in to sharing it). A request without a user id never reaches a function that declares profile; the position is served from disk instead. |
target |
schema value | The target's captured data, loaded from disk against the schema (target.sessions is the captured Map). Refreshed after every upload. |
node |
schema value | The nearest enclosing Struct: for s.sessions.for_each()._metadata._thumbnail the Session whose thumbnail was requested, and for s.sessions.for_each().photos.for_each() that same session. The whole target when no crossed container holds structs (s.photos.for_each() at the root). |
So def notification(profile, target) and def count(target) are both
valid. Declaring any other required parameter raises
DefinitionError at import time. The return annotation is not
checked; the returned value is, on every request (see below).
Interceptor errors fall back to disk
If an interceptor raises, or returns a value that does not coerce to the
position's type, the server logs the error and the request falls back to
whatever is on disk at that path, usually a 404. Clients never see a
500. When an intercepted file mysteriously "doesn't exist", read the
server log or the target status in the management panel.
Persistence#
Interceptors hold no durable state of their own: everything they memoize or
carry across requests lives in the recipe scratch the server opens at
startup, <root>/.cg-scratch or the scratch_path in config.toml (see the
recipes guide). An interceptor never configures the
scratch itself; it just calls recipes. Two mechanisms cover the two needs:
- Pure memoization — expensive, deterministic work (forecasting,
void-and-cluster selection, thumbnail rendering) in a
cg.pure_function, keyed by the content of its arguments, so the body runs once per distinct input and every later request with the same inputs is served from the cache. - Keyed impure state — coordination that evolves (who holds which slot)
in a
cg.ImpureStateclass: fields are the persistent book, methods its operations, and each call is one serialized, cross-process read-modify-write. Theopenkey shards one book per target. The scheduling books (ReservationBookandCoverageBook) are exactly this.
The scratch is pure speedup plus in-flight coordination: the captured data on
disk is the durable record, so deleting .cg-scratch forgets current
reservations and forces a recompute, nothing else. The server relies on that:
a scratch left behind by an older layout is discarded and rebuilt at startup,
so an upgrade never leaves every recipe-backed position failing.
What the server enforces#
- Position by path. A request is intercepted only when its full relative
path matches a claimed pattern,
*standing for exactly one component; a file with the same name elsewhere is served from disk as usual. A pattern crossing no container names one file and never reaches into aMaporArrayentry. - Type resolution and coercion. The result type is resolved from the
schema at the position, and the value is coerced to it before serving: a
bare value fills a single-field scalar (a
datetimefor acg.Time, a number for acg.Number), a path fills a file scalar if its extension is one the type accepts, and anything else is rejected. - Write protection. A client upload to a shadowed position is refused
with HTTP
409;skip_if_exists=Truelifts the refusal. - Delivered dynamically. On the wire an intercepted position resolves as a
dynamic leaf: a
presentverdict withdynamic: true, a freshly computedversion, and bytes carried inline. A raw download carries the version as itsETag, so a matchingIf-None-Matchstill yields304. See the captured-data format for the full contract. - Dry run on load. Every interceptor whose selector crosses no container is called once when the target loads, with a throwaway user and inside a throwaway scratch, and its result or error is reported in the target's status. One crossing a container has no single position to dry-run and is skipped.
Scheduling lives here#
Interceptors are where the scheduling library
runs: a pure recipe such as
[notification_candidates][server.notifications.candidates.notification_candidates]
chooses which times to offer, and an impure book such as
ReservationBook
gives each participant a distinct one. The walkthrough builds the
temporal and the
spatial version line by line and
ends with both complete modules.
Editing safely: the validation pipeline#
Procedure saves from the management panel never touch the live module directly:
- The candidate is written to a hidden pending file at the target root.
- A subprocess imports it, finds and builds the
@cgserver.targetprocedure, serializes the result, and checks every interceptor declaration, killed aftervalidation_timeoutseconds if it hangs. - Only on success is the current live module snapshotted into
.history/(newesthistory_limitkept) and the pending file swapped in atomically.
A broken save therefore can never brick a live target, and every previous version is one click away in the panel's History view; restores run through the same pipeline, so they are themselves undoable.
Editing the module directly on disk (over SSH, say) skips all of this: the edit goes live on the next request, valid or not. That is sometimes what you want, but the panel's editor is the safer default.