Architecture¶
This document describes the overall Workpeg platform model, how the SDK fits into it, and the design decisions behind the tools.
What is a Peg?¶
A Peg is a portable micro-application distributed through the Workpeg ecosystem. A Peg packages:
- Backend logic as one or more Functions
- A UI definition (schema or compiled)
- Static assets (CDN)
- Metadata and configuration
Pegs are distributed through the PegStore, where they can be discovered, installed, and embedded.
Developer
↓
write Python
↓
Workpeg SDK
↓
functions + UI + assets
↓
Workpeg Registry / CDN
↓
PegStore
↓
End users
SDK namespaces¶
The CLI follows a namespace pattern:
| Namespace | Commands | Purpose |
|---|---|---|
function |
new, build, run, runtime, submit |
Backend function lifecycle |
cdn |
submit |
Static asset distribution |
ui |
new, serve, build |
Python UI → browser JS compiler + dev server |
project |
create |
Scaffold a whole peg (UI + function + manifest) |
Each namespace is a self-contained module in src/workpeg/:
src/workpeg/
├── cli.py ← namespace router
├── create_new.py ← function new
├── project.py ← project create (the manifest + function template)
├── starter.py ← the Scaffold starter and AGENTS.md both creators write
├── build.py ← function build (Docker)
├── run.py ← function run
├── runtime.py ← function runtime (and its invoke key)
├── function_envelopes.py ← the three envelopes the API sends a function
├── function_local.py ← one call in a child process: function test, process mode
├── listen.py ← a dev install's function runner: project serve, ui serve --listen
├── submit.py ← function submit
├── cdn.py ← cdn submit
├── config.py ← workpeg.json loader
├── context.py ← context validation
└── ui/
├── app.py ← App, Router, Shell, Component + the sentinels (route, store, clock, …)
├── state.py ← State, Binding
├── widgets/ ← the widget set (layout, display, interactive, forms, canvas, gestures, futures)
├── scaffold.py ← the app-chrome slots (Scaffold, the NavItem family) — the chrome itself is runtime/scaffold.js
├── actions.py ← schema-UI actions (SetState, Increment, Navigate, RunFunction)
├── validators.py ← Form/Field validators
├── build.py ← build_ui() orchestrator
├── minify.py ← the dist/ minifier
├── create.py ← ui new: writes the starter, then builds it
├── serve.py ← local HTTP dev server (live reload, error overlay)
├── watcher.py ← FileWatcher (mtime polling)
├── fake_cloud.py ← the dev loop's hosts lines and names, and dev-clean
├── devmode.py ← serve --fake-cloud: the checks made before anything is bound or sent
├── dev_refused_names.json ← serve --fake-cloud: the SDK's copy of the API's refused names
├── devsite.py ← serve --fake-cloud: the release build, and what is served from it
├── devserve.py ← serve --fake-cloud: port 443, this computer only, the Host check, the hosts line and the certificate
├── runtime/ ← workpeg-runtime.js, pegment.css, icons.css
└── compiler/
├── ast_parser.py ← Python → IR
├── ir.py ← IR dataclasses
└── js_generator.py ← IR → JavaScript
The two UI systems¶
The SDK ships two distinct approaches to UI, for two distinct delivery models.
Schema UI¶
The developer builds a Python object graph. .to_schema() serialises it to JSON. The Workpeg platform renderer reads the JSON and renders the UI on the client.
Used for: Pegs that run inside the Workpeg ecosystem, where the renderer is already present.
UI Compiler¶
The developer writes a Python class. The compiler parses the AST (no code execution), builds an intermediate representation, and emits vanilla JavaScript. The output is a self-contained directory that opens directly in a browser.
Used for: standalone browser apps, prototypes, and Pegs that need to run outside the platform renderer.
The two systems share the same widget names (Column, Row, Text, Button) and state concept (State), but the semantics differ:
| Concept | Schema UI | Compiler |
|---|---|---|
State |
State({"key": value}) dict |
field = State(value) descriptor |
| Children | Column(children=[...]) keyword |
Column(Text(...), Button(...)) positional |
| Reactivity | Platform-driven | JS Proxy + full DOM replace |
| Actions | Action objects |
Handlers — method references or lambdas (on_click=self.method, on_change=lambda v: ...) |
UI Compiler pipeline¶
workpeg ui new counter workpeg ui build counter/
│ │
│ create.py → starter.py │
│ write the starter + AGENTS.md │
▼ │
counter/main.py ────────────────────────────┘
│
│ ast.parse(source)
▼
ast.Module
│
│ ASTParser._parse_class()
▼
AppIR ─────────────────────────────────────────────
│ .states: [StateIR(name, default)] │
│ .methods: [MethodIR(name, statements)] │ .to_dict()
│ .tree: WidgetIR(type, props, children) │
│ ▼
│ JSGenerator.generate(ir) the IR as JSON
▼
app.js
│
│ + workpeg-runtime.js
│ + index.html
▼
dist/
│
│ serve.py (ui new only)
│ http.server.SimpleHTTPRequestHandler
▼
http://localhost:8080
│
│ serve.py (ui new / ui serve)
│ FileWatcher polls mtime → recompile → SSE broadcast
▼
Browser reloads on file change
AST Parser¶
- Uses Python's built-in
astmodule — noexec, noeval - Walks the class body to find
State(...)assignments, method definitions, and thebuild()return - Supports a strictly defined subset of Python (see Core concepts for supported syntax)
- Raises
UnsupportedSyntaxErrorfor anything outside the subset - Output is language-neutral
AppIR
IR (Intermediate Representation)¶
Dataclasses in compiler/ir.py. Fully JSON-serializable via .to_dict(). The IR sits between the parser and the JS generator and can be inspected, tested, or transformed independently.
AppIR
├── StateIR[] → reactive state fields
├── MethodIR[] → event handlers
│ └── StatementIR[] → state mutations
└── WidgetIR → build() tree
└── WidgetIR[] → children (recursive)
JS Generator¶
- Takes
AppIR, outputs a JavaScript string - Generates a
classwith aProxy-based reactive state - Compiles f-strings like
f"Count: {self.count}"→ JS template literals`Count: ${this.state.count}` - Generates a
window.addEventListener("load", ...)bootstrap block - No external dependencies in the output
Browser Runtime¶
workpeg-runtime.js is one dependency-free IIFE that exposes window.Workpeg:
Workpeg.h(type, props, children)— virtual node factoryWorkpeg.mount(app, element)— renders app into DOM, replaces contentWorkpeg.render()— re-renders current app (called by the Proxy setter)- plus the SPA router, the widget renderers, and the platform clients
(
http.*/workpeg.*, sockets, SSE, and table-change events, whose token only the runtime holds)
No VDOM diffing. On every state change, build() is called fresh and the
subtree is replaced — with the interactive context preserved across the
rebuild: input focus and caret, list scroll positions, and an open dropdown
Menu (same-URL re-renders only) are recorded before the wipe and restored
after, so a keystroke-driven re-render doesn't fight the user.
Where the chrome comes from¶
Two surfaces are not compiled into your app: the Scaffold's chrome
(scaffold.js — the rail, business switcher, Search, the top bar) and the
Workpeg AI surface (ai.js). Your build carries only your slot content; the
runtime fetches these bundles lazily, the first time a Scaffold or a
/b/<business>/chats/* route mounts.
They're published to the CDN on every change, so the Workpeg team ships chrome updates to your app without you rebuilding it — a design fix reaches every Peg within minutes, whatever SDK version you pinned.
That only works if your app actually asks the CDN, which is a build-time choice baked into the dist:
workpeg ui build myapp/ # auto (the default)
workpeg ui build myapp/ --chrome bundled # same-origin only
auto (default) |
bundled |
|
|---|---|---|
| First choice | the CDN's current copy | your app's bundled copy |
| Fallback | your app's bundled copy | — |
| Touches the network | yes, at boot | never |
The choice travels as data-chrome on the runtime's own <script> tag, so
grep data-chrome dist/index.html answers "where does this app's chrome come
from?" — and silence means auto. Your dist ships the bundled copy either
way; this only decides whether it's the first choice or the fallback.
bundled is for local development and tests. A released peg is never built
with it. Under bundled the chrome is frozen at the SDK version you built
with, and a Workpeg update never reaches it. (workpeg ui serve uses bundled
— a dev loop should show you the code on your disk.)
auto degrades rather than breaks. If the CDN is unreachable, blocked, slow
(past a 1.5s budget, behind the chrome's own loader), or serves a response
that doesn't publish the bundle, the runtime loads your bundled copy instead
and remembers the failure for the rest of the session. The worst case is the
chrome you shipped with — i.e. bundled's behavior.
Because a CDN bundle runs against whatever runtime your app pinned, the
handoff (mount(host, ctx)) is a versioned public API: ctx.abi is stamped
by the runtime, and a bundle must treat a missing abi as 0.
The script policy every build carries¶
A peg is Python only, and every page a build writes says so itself. Its
index.html carries a Content-Security-Policy in a <meta>, straight after
the charset and ahead of every script:
script-src 'self' https://cdn.workpeg.com/ai/ai.js https://cdn.workpeg.com/scaffold/scaffold.js https://cdn.workpeg.com/editor/editor.js; object-src 'none'; base-uri 'self'
A page can run script from its own origin and from the exact CDN URLs of the
SDK's three bundles, and from nothing else. A peg that uses WebRTC also loads
the SDK's rtc.js, from its own origin like the runtime, so the policy is the
same for it. An inline script, an inline handler, a javascript: URL or a
string turned into code does not run. The CDN
serves more than the SDK, so the policy names three files and never the host.
A browser ignores a source's path after a redirect, so the host that serves a
peg must not answer a script request with one.
index.html is the only page a build writes, so the policy covers the whole of
a peg. The build stops on a page or a script in public/: see
What public/ can hold.
The words are the same on every build, auto or bundled, and under
workpeg ui serve. No argument, flag or environment variable changes them, and
nothing the SDK puts on a page needs anything wider. The dev server's own
script is a file for this reason: /_workpeg_reload.js, served from memory and
never written to dist/.
You don't need to send a header for this, and a host's header can only tighten
it. A browser applies every policy a page has, and a script must pass all of
them, so a header can take something away and cannot give anything back:
unsafe-inline in a header runs nothing the page's own policy refuses.
If your host does send a script-src, have it name those same three URLs. A
header of script-src 'self' breaks nothing you can see, and that is the
problem with it: the runtime reads the refusal as a CDN that is down and takes
your bundled copy, so the chrome stops updating with no error on the page.
The trust this asks for¶
Under auto, those three files on cdn.workpeg.com execute JavaScript in your
users' sessions at boot, before your app renders. Subresource Integrity can't
help: an integrity hash pins the bytes, which is exactly the out-of-band change
this exists to allow. That is what lets the Workpeg team update every peg at
once, and why the files stay backward compatible with the apps already deployed.
"The chrome didn't update"¶
The failure is quiet by design — the point of the fallback is that a bad CDN looks like a working app — so diagnose it rather than refreshing:
// In the console, on the page in question:
Workpeg._chrome // "auto" | "bundled"
[...document.querySelectorAll('script')]
.map(s => s.src).filter(s => /scaffold\.js/.test(s))
- A
/scaffold.js?v=…URL — you got the bundled copy. Either the build isbundled, or the CDN attempt failed (refused by a stricter header from your host, blocked by an ad blocker, offline, or slower than the budget) and the session is now pinned to the fallback; open a fresh tab to retry the CDN. Workpeg._chromeis"bundled"when you expectedauto— the build was made with--chrome bundledorWORKPEG_CHROME=bundled. Noteworkpeg ui serveis alwaysbundled.- A
cdn.workpeg.comURL but old-looking chrome — the CDN copy is cached; it has a 300s TTL. - Neither, and the shell is a spinner — nothing published
mount. Check the console for a load error.
An app must be rebuilt once on this SDK or later to load chrome from the
CDN at all: the loader lives in workpeg-runtime.js, which is baked into your
dist. An app that is never rebuilt keeps serving the chrome it shipped with,
forever — which is exactly how this promise went unkept before: every app
loaded /scaffold.js same-origin, so a CDN publish reached nobody, silently.
Function runtime pipeline¶
stdin JSON (or HTTP POST /invoke)
│
│ parse {"context": ..., "payload": ...}
▼
runtime.py
│
│ load_function(entrypoint) ← import module, find function
▼
user function: main(context, payload)
│
│ call + capture result or exception
▼
{"status": "success", "result": ...}
or
{"status": "error", "error_type": ..., "error": ...}
│
▼
stdout JSON (or HTTP 200 response)
The runtime runs as either:
- A one-shot process (stdin → stdout) for fast local iteration
- A persistent HTTP server (
--server) with a warm function handle and/invoke+/healthzendpoints
Context validation¶
The context passed to a function is validated before reaching user code:
ContextBuilder.add("table", raw_dict)
│
│ _TableContext.verify(raw_dict)
│ → checks required keys, UUID format, types
▼
ContextBuilder.build()
│
▼
clean context dict
Context types are registered in a ContextBuilder.registry. New context types can be added by calling ContextBuilder.register(key, HandlerClass) where HandlerClass implements a verify(data) classmethod.
Authentication model¶
Both function submit and cdn submit use a single JWT token (WORKPEG_PK),
generated from the developer dashboard — see
Getting a token.
The SDK:
- Base64-decodes the JWT payload (no signature verification client-side)
- Extracts
peg_namespaceto construct the correct API URL - Passes the token as
Authorization: Bearer <token>
The server validates the signature and authorizes the operation.
Configuration resolution¶
workpeg.json is deep-merged over the SDK defaults:
DEFAULT_CONFIG = {
"function": {"entrypoint": "app.main:main"},
"runtime": {"default": "cracker", "docker": {"host": "127.0.0.1", "port": 8000}},
"build": {"image": None},
}
Missing keys fall back to defaults. Only explicitly set keys in workpeg.json override defaults. Arrays are replaced, not merged.
Design principles¶
No magic execution. The UI compiler uses only the Python ast module. It never imports user code, never calls exec or eval. Errors are always the result of unsupported syntax, never of runtime failures in user code.
Namespace-oriented CLI. Commands are grouped under workpeg function, workpeg cdn, workpeg ui rather than a flat command space. This mirrors tools like Docker and kubectl, and makes the CLI easy to extend as the platform grows.
JSON as the interface. The function contract (stdin/stdout JSON), the schema UI (.to_schema()), and the IR (.to_dict()) all serialize to JSON. This keeps every layer observable, testable, and platform-neutral.
Explicit over implicit. Unsupported syntax raises an error immediately with a clear message. The compiler does not silently skip unknown constructs.
Deterministic output. The same Python source always produces the same JavaScript — no timestamps, no environment-dependent output. (The ?v= cache-buster in index.html is a content hash, so it too is identical for identical input.)
Peg progression¶
Developer
│
│ workpeg project create (or workpeg function new for a function alone)
▼
Peg project — ui/ + function/ + workpeg.json
│
│ workpeg function build
▼
Docker image
│
│ workpeg function submit
▼
Workpeg Registry
│
│ workpeg cdn submit
▼
CDN assets
│
│ (platform packages function + assets)
▼
Peg in PegStore
│
│ users discover and install
▼
End user experience
Roadmap direction¶
The SDK is designed to grow in layers, each adding capabilities without replacing the ones below:
| Layer | Now | Next |
|---|---|---|
| Function runtime | Docker, local stdin/stdout, persistent --server |
Firecracker microVM |
| Scaffolding | workpeg project create (whole peg), workpeg ui new (multi-page counter template + dev server), workpeg function new |
Multi-template --template flag |
| UI compiler | The full widget set, routing/shells, components, stores, async/data, canvas, the compiled Python subset | Grow the subset as real Pegs demand it |
| App chrome | Scaffold — a special widget whose chrome (rail, switcher, top bar, Apps launcher) is drawn by scaffold.js, loaded from the CDN so it updates out of band |
ai.js — the Workpeg AI surface behind /b/<business>/chats/*, loaded the same way |
| Dev server | compile + serve + live reload + error overlay; the dev install (--fake-cloud: the release build at https://<namespace>.dev.workpeg.com, this computer only, and dev-clean) |
— |
| Distribution | Registry publish, versioned releases to PyPI (make release) |
PegStore submission workflow |
| CLI | function, cdn, ui, project (create) |
project submit; registry, client namespaces |