Skip to content

Running a Server#

A single iOS device can capture and store data entirely on its own — see Analyzing Your Data for the zero-server workflow. The CaptureGraph server becomes useful when a capture project outgrows one phone:

  • Remote targets — the app downloads procedures from the server, and uploads captures back, so many participants contribute to one dataset.
  • Live editing — procedures are plain Python files on the server; edits go live without redeploying the app.
  • Scheduling — server-side interceptors decide when and where each user should capture next, using the scheduling library.
  • Management panel — a web UI for managing targets, browsing captures, and editing procedures.
  • Syncingcapturegraph-sync pulls captured data to your analysis machine incrementally.

Installation#

The server is a member of the monorepo's uv workspace, so the standard uv sync installs it along with the library (see Installation):

git clone https://github.com/CaptureGraph/CaptureGraph.git
cd CaptureGraph
uv sync

This provides the capturegraph-server command (run it via uv run capturegraph-server, or activate .venv first).

Starting the server#

The server runs from a root directory: every subdirectory containing a __capture_target__.py file becomes a hosted capture target.

mkdir my-deployment && cd my-deployment
capturegraph-server serve

On first launch the server:

  1. Creates a commented config.toml in the root.
  2. Attempts a UPnP port-forward on your router (so phones outside your network can reach it) and prints the public URL if it succeeds.
  3. Prints the local URL (default port 4433) and the interactive API docs at /docs — plus a note that the management panel is disabled until you set management_password in the freshly created config.toml.
  4. Discovers and validates targets in the background — the server answers requests immediately, and targets come online one by one as they validate.

Targets are re-scanned from disk every target_refresh seconds (default 30), so dropping a new target directory into the root requires no restart.

Pointing the iOS app at the server#

In the app, open Settings → Remote Server Settings and enter the server's URL in Server Address — include the http:// or https:// prefix, for example http://192.168.1.20:4433 or https://capture.example.org. The app then lists every hosted target alongside its local procedures.

CLI reference#

capturegraph-server serve [OPTIONS]
Flag Description
--root PATH Server root containing targets and config.toml. Must exist. Defaults to the current directory.
--mount, -m PATH:/url Serve a static directory at a URL prefix (repeatable), e.g. -m ./www:/static.
--host TEXT Bind address for uvicorn.
--port INTEGER Internal TCP port for the API.
--public-port INTEGER External port requested from the router via UPnP; defaults to --port.
--reload Hot-reload for development: uvicorn restarts the server whenever a .py file under the current directory changes. Cannot be combined with --headless.
--headless Detach from the terminal and keep running after it closes (POSIX only). Output goes to <root>/.server.log.
--no-delete / --allow-delete Disable or enable the client delete endpoints. Unset falls through to config.toml.

Only host, port, public_port, and no_delete can be overridden from the CLI; mounts given on the CLI are merged with those in config.toml, and everything else (TLS, UPnP, management settings) is configured only in the file.

Single-instance handling#

serve refuses to start while another server is already running from the same root: it reads <root>/.server.pid, verifies the PID is alive and actually looks like a CaptureGraph server, and (interactively) offers to stop the old instance first. A stale pidfile left behind by a crash is detected and ignored. To stop a headless server:

kill $(cat .server.pid)

Configuration: config.toml#

config.toml lives in the server root and is auto-created with a commented template on first launch. Precedence is CLI flag → config.toml → built-in default.

Key Default Description
management_password "" Top-level key. Password for the management panel; the panel and its API stay disabled while blank.
[server] host "0.0.0.0" Bind address.
[server] port 4433 Internal TCP port.
[server] public_port same as port External port for the UPnP mapping.
[server] no_delete true Disable the client (app) delete endpoints; the management panel can always delete.
[server] upnp true Attempt UPnP port forwarding on startup.
[server] target_refresh 30 Seconds between disk re-scans for new/deleted targets (0 disables).
[server] worker_threads 64 Thread pool size for filesystem work.
[server] behind_tls_proxy false Set true behind an HTTPS reverse proxy so the management cookie is marked Secure.
[server] ssl_certfile / ssl_keyfile unset Serve TLS directly from uvicorn (prefer a reverse proxy instead).
[management] history_limit 100 Max snapshots kept per target in .history/.
[management] validation_timeout 20 Seconds before a procedure validation subprocess is killed.
[[mounts]] path, url Repeatable static directory mounts (see below).

A config typo never takes the server down — but it disables the panel

If config.toml fails to parse, the server logs the error and starts with all defaults — including a blank management_password, so the management panel goes dark even if the broken file set one. Check the startup log after editing the config.

Static mounts#

To serve extra static content (reference imagery, a project landing page) alongside the API, mount directories either from the CLI or the config:

capturegraph-server serve -m /srv/site:/about
[[mounts]]
path = "/srv/site"
url = "/about"

CLI mounts and config mounts are merged; each directory is served with StaticFiles(html=True), so an index.html works as a directory index.

UPnP#

By default the server asks your router (via UPnP/miniupnpc) to forward public_port to the machine it runs on and prints the resulting public URL. Failure is non-fatal — the server still starts and remains reachable on the LAN, or publicly if you forward the port manually. Set upnp = false on deployments behind a proper reverse proxy.

Files the server maintains#

In the server root:

File Purpose
config.toml Configuration, auto-created from a commented template.
.session_secret Signing secret for management sessions (mode 0600, auto-created). Logins survive restarts because of this file.
.server.pid PID of the running instance, used for single-instance handling.
.server.log stdout/stderr when running with --headless.

In each target directory:

File Purpose
__capture_target__.py The target definition you write.
.persistence.db Durable per-target state backing the state argument of interceptors: a SQLite store (WAL mode), read-modify-write inside a BEGIN IMMEDIATE transaction so concurrent requests serialize across worker processes.
.history/ Timestamped snapshots taken before every validated save from the panel.
procedure_nodes.json A node-mode target's saved graph — only for the visual node editor, which is currently deferred.

There is no per-target manifest file: a target's data layout is its schema, which travels inside the procedure definition (see Targets and Interceptors) and is served from definition.

Client API#

The unauthenticated client API under /api/v1/targets is what the iOS app and capturegraph-sync speak. Target names are root-relative paths, so grouped targets nest naturally (/api/v1/targets/Plants/Tulip/...). Interactive documentation is served at /docs.

Endpoint Purpose
GET /api/v1/targets List hosted target names.
GET /api/v1/targets/{target}/definition The target's serialized procedure as {schema, root, nodes} (re-executes the target module, so edits go live immediately). The embedded schema is the data layout.
GET /api/v1/targets/{target}/directories, .../directories/{path} List the entries at a path — enumerates dynamic Map/Array directories (key-encoded folders and zero-padded indices alike). Sync and the iOS client walk the schema with this to discover what to download.
GET /api/v1/targets/{target}/files/{path} Stream a leaf's bytes — or an interceptor's freshly computed result — with ETag/X-CG-File-Extension headers; a matching If-None-Match yields 304.
PUT /api/v1/targets/{target}/files/{path} Upload one leaf as the raw request body (extension in X-CG-File-Extension); returns a present leaf state. Returns 409 for interceptor-generated paths.
POST /api/v1/targets/{target}/files:resolve Batch leaf-resolve used for incremental sync: per leaf, unchanged / present / missing against the client's cached version.
POST /api/v1/targets/{target}/files:save Upload many small files inline in one round trip; validated and written all-or-nothing.
DELETE /api/v1/targets/{target}/files/{path}, .../directories/{path} Deletes; return 403 while no_delete is set.

Next steps#