Quickstart
Install the SDK, write a manifest, ship one protected route, publish to the Control Plane, and emit an event — end to end.
Before you begin
This quickstart builds a minimal SaaS app that runs on the SynaptaGrid Platform. By the end you will have a manifest the Control Plane accepts, a portal route guarded by the platform's five gates, and one event flowing through the outbox onto Kafka as a CloudEvents envelope.
You will need:
- Python 3.11+ — the app runtime SDK requires it.
- A Control Plane base URL and API key — the Control Plane is the single source of platform config; publishing your manifest is how the platform learns your app exists.
- A code editor. That's it — no local Kafka or Postgres is required to write and validate a manifest.
One idea to hold onto: the SDK is both a runtime and a CLI. The runtime (create_saas_app, the tier routers, the gates) is what your service imports; the CLI (egav-app-sdk) is what validates and publishes your manifest. Every platform-native app ships exactly one manifest at backend/app.manifest.yaml.
Step 1 — Install the SDK
The Python app runtime is the egav-app-sdk package (import name egav_app_sdk). Installing it also registers the egav-app-sdk console command used in later steps.
The SDK and the platform client packages it depends on are private GitHub repositories with no public PyPI name, so you need a GitHub token with read access to them (ask your platform contact for access). Pinning egav-app-sdk alone does not install: it depends on about nine private packages (egav-base, egav-logging, egav-auth-client, egav-authz-client, egav-cp-client, egav-audit-client, egav-notifications-client, egav-outbox, egav-worker-runtime), and pip can only resolve each one from its own tarball pin. Without those pins the install fails with No matching distribution found for egav-logging.
Start from the service scaffold. The egav-template repository is a complete backend service that boots, migrates and passes CI, and its backend/requirements.txt already pins every private package at a compatible release tag. Copy it and replace its example domain with your own; that is quicker than assembling the pin set by hand.
Pin release tags, never a branch: a main pin changes what you install with no change in your repository.
# One line per private package, each at a release tag. Copy the full set from egav-template.
egav-app-sdk @ https://api.github.com/repos/djokodonev/egav-app-sdk/tarball/v0.114.1
egav-base @ https://api.github.com/repos/djokodonev/egav-base/tarball/v1.22.57
# ... the remaining private packagesThe pins carry no token. In a Docker build the token is supplied as a BuildKit secret (the scaffold's Dockerfile shows how). For an install on your own machine, give pip the token through a credential helper or ~/.netrc (machine api.github.com login x-access-token password <token>), never inside a committed file.
# Install into your service
pip install -r requirements.txt
# Or, if you're working inside a checkout of the SDK itself:
pip install -e ".[dev]"
# Verify the CLI is on your PATH:
egav-app-sdk --versionStep 2 — Write app.manifest.yaml
The manifest is your app's contract with the platform. It uses a fixed Backstage-style top level — apiVersion, kind, metadata, and spec are all required, apiVersion is always the constant platform.synaptagrid.io/v1, and kind is always Application. Inside spec, only features is required; everything else (permissions, roles, events, GDPR, audit, config) is optional and added as you need it.
Here is a real, minimal manifest — the reference app the SDK's own test suite loads. metadata.name is your app's machine code (lowercase, ^[a-z][a-z0-9-]*$); version must be semver:
apiVersion: platform.synaptagrid.io/v1
kind: Application
metadata:
name: hello-world # machine code — not "code:"
display_name: Hello World
version: 1.0.0 # 1.0.0 or later; 0.x is refused at startup
owner: platform-team
publisher: getek
release_status: alpha
spec:
cp_permissions:
- hello_world.greetings.read
- hello_world.greetings.write
cp_roles:
- code: hello_world_editor
name: Hello World Editor
permissions:
- hello_world.greetings.read
- hello_world.greetings.write
features: # the only required spec section
- code: hello_world.write
name: Create greetings
description: Create new greetings.
ui_hint: Allow creating and updating greetings.
type: boolean
default: false
health:
liveness: /health
readiness: /healthegav-app-sdk manifest validate backend/app.manifest.yaml
# -> Manifest OK: name='hello-world' version='1.0.0' apiVersion='platform.synaptagrid.io/v1'Validation is two-pass — and the Control Plane runs the same code
manifest validate runs the exact validator the platform uses. Pass 1 checks your manifest against the strict JSON Schema (Draft 2020-12, additionalProperties: false at every level). Pass 2 runs the cross-field checks a schema can't express — for example, every role's permissions must resolve within its own scope list, and every emitted event's topic must match one of your declared spec.kafka.owned_topic_patterns.
The important guarantee: when you publish in Step 4, the Control Plane re-runs this same SDK validation. A manifest that passes locally passes on publish — there is no second, divergent schema to satisfy.
One check runs only when your service starts: an egav_base service also registers its settings with the Control Plane, and that registration needs metadata.version at 1.0.0 or later and at least one spec.config tab. See Manifest for both rules.
Note on the manifest keys. A few keys are easy to guess wrong: authorization is not oneauthz:block and the audit catalogue is notaudit.catalog. The strict schema usesspec.resource_types/spec.cp_permissions/spec.cp_roles,spec.audit.event_types, and top-levelspec.notification_templates. Always anchor on the validated example above.
Step 3 — Protect one route with the five gates
Build the app with create_saas_app and mount routers per trust tier: make_public_router (no auth), make_portal_router (tenant users), make_cp_router (operators), make_internal_router (service-to-service). Inside a handler, get_app_context resolves the tenant and user for you.
The reference app guards a route with the SDK's decorator gates — feature and quota — reading the tenant from AppContext:
from fastapi import Depends, Request
from egav_app_sdk import (
AppContext, get_app_context, requires_feature, enforce_quota,
make_portal_router, create_saas_app, load_manifest,
)
MANIFEST = load_manifest("backend/app.manifest.yaml")
portal = make_portal_router(MANIFEST.identity.app_code)
@portal.post(
"/greetings",
dependencies=[
Depends(requires_feature("hello_world.write")),
Depends(enforce_quota("hello_world.greetings.count")),
],
)
async def create_greeting(request: Request, ctx: AppContext = Depends(get_app_context)):
body = await request.json()
# ctx.user_guid / ctx.org_guid / ctx.tenant_application_instance_guid are resolved for you
return {"success": True, "data": {"text": body.get("text", ""), "user_guid": ctx.user_guid}}
app = create_saas_app(
manifest=MANIFEST, settings=_Settings(), instance_resolver=_instance_resolver,
routers={"portal": portal},
)The full five-gate reference route
A fully-wired production route composes all five gates as FastAPI dependencies. Two are authentication + authorization, three are entitlement:
- Authentication — is this a valid, unexpired, non-revoked portal token?
- Permission — may this user perform this action on this resource?
- Subscription — is the org's subscription active?
- Feature — is this capability in the org's plan?
- Quota — is the org within its usage limit?
RequireAuthz (from egav-authz-client) does gates 1 and 2 in a single dependency — it validates the portal Bearer JWT and checks the permission against the Authorization service. The three entitlement gates come from egav-cp-client:
from fastapi import Depends, Request
from egav_app_sdk import make_portal_router, get_app_context, AppContext
from egav_authz_client import RequireAuthz
from egav_cp_client.feature_gates import (
require_active_subscription, # -> 402 when the subscription is lapsed
require_feature, # -> 403 when the feature isn't in the plan
require_quota, # -> 429 when the org is over its limit
)
portal = make_portal_router(MANIFEST.identity.app_code)
async def greetings_count(request: Request) -> int:
# your own usage probe, scoped to the caller's org
return await count_greetings(request.state.identity.org_guid)
@portal.post(
"/greetings",
dependencies=[
Depends(RequireAuthz("hello_world.greeting", "create")), # 1 auth + 2 permission
Depends(require_active_subscription), # 3 subscription
Depends(require_feature("hello_world.write")), # 4 feature
Depends(require_quota("hello_world.greetings.count", # 5 quota
current_usage=greetings_count)),
],
)
async def create_greeting(request: Request, ctx: AppContext = Depends(get_app_context)):
body = await request.json()
return {"success": True, "data": {"text": body.get("text", ""), "user_guid": ctx.user_guid}}How the gates fail — one status per failure
Every gate fails closed: if the Authorization service or Control Plane is unreachable, the request is rejected, never waved through. The status contract is stable and worth designing your clients around:
| Status | Meaning |
|---|---|
| 401 | Missing, invalid, expired, or revoked token |
| 402 | Subscription lapsed (payload includes an actionable renew/upgrade link) |
| 403 | Permission denied, feature not in plan, or wrong trust tier |
| 429 | Quota exceeded |
| 503 | A dependency (AuthZ / Control Plane / Redis) is unreachable — fail closed |
Two accuracy notes for real wiring:
RequireAuthzbinds to a declared resource. Its first argument is aspec.resource_typescode — declare the resource you're guarding (with itsactions) in your manifest before referencing it here.- Order matters.
RequireAuthzresolves the portal identity and writes it torequest.state.identity; the three entitlement gates readorg_guidfrom there. KeepRequireAuthzfirst in thedependencieslist so the org context exists when the entitlement gates run.
Step 4 — Publish the manifest to the Control Plane
Publishing registers your app and syncs its manifest sections outward — the Control Plane hands your resource types, permissions, and roles to the Authorization service, your templates to Notifications, your scheduled tasks to the scheduler, and your events to the event bus.
The CLI reads EGAV_CP_URL and EGAV_CP_API_KEY from the environment and POSTs the manifest to the Control Plane's internal applications endpoint (authenticated with x-api-key). It inlines your event schemas and notification-template refs before upload, so the Control Plane stores everything it needs without fetching from source control.
EGAV_CP_API_KEY is your app's own caller key, not a shared platform key. Each app owns one key and presents it to every service it calls; the Control Plane only accepts it once it is registered in the Control Plane's SERVICE_API_KEYS under your app code (see What a caller puts on the wire). In a platform deployment the key is provisioned from your manifest's spec.required_env; on a local stack an operator adds <your-app-code>:<key> to the Control Plane's SERVICE_API_KEYS and restarts it. Without a registered key the publish is refused with 401.
An operator can also register the app without any key: in the Control Plane console, Applications → Import manifest… uploads the same file.
# The Control Plane API host, not the console's
export EGAV_CP_URL=https://api.your-domain.example:5207
export EGAV_CP_API_KEY=<your app's caller key>
egav-app-sdk manifest publish backend/app.manifest.yaml --app-code hello-world
# POST {EGAV_CP_URL}/v1/control-plane/internal/applications/hello-world/manifest
# -> Manifest PUBLISHED: app_code='hello-world' version='0.1.0'EGAV_CP_URL is the Control Plane API host, https://api.your-domain.example:5207 in this example. The console's host does not serve the API, so a publish sent there fails or returns a web page instead of an API response.
Trusting the local certificate from your machine
The local stack serves HTTPS with certificates issued by a local mkcert root. Browsers trust that root once mkcert -install has run, but Python tools on your machine (the egav-app-sdk CLI, httpx, requests) read their own CA bundle and fail with CERTIFICATE_VERIFY_FAILED. Give them a bundle that contains both the public roots and the mkcert root:
# Public roots + the local mkcert root, in one file
cat "$(python -m certifi)" "$(mkcert -CAROOT)/rootCA.pem" > local-ca-bundle.pem
export SSL_CERT_FILE="$PWD/local-ca-bundle.pem"# The same on Windows PowerShell
Get-Content (python -m certifi), "$(mkcert -CAROOT)\rootCA.pem" | Set-Content local-ca-bundle.pem
$env:SSL_CERT_FILE = "$PWD\local-ca-bundle.pem"Keep the public roots in the bundle: a file holding only the mkcert root makes every call to a public host fail instead. Never turn certificate verification off to get past this error.
How the Control Plane reaches your app
Publishing tells the Control Plane what your app is, not where it runs. The Control Plane calls your app for provisioning and the other lifecycle hooks (see Provisioning), so it needs your app's address:
- In a platform deployment, the platform provisions your app and records its address. Nothing to do.
- On a local stack, an operator adds two lines to the Control Plane's
backend/.env, named after your app code in upper case with hyphens as underscores (hello-world→HELLO_WORLD):
``bash HELLO_WORLD_API_URL=https://<your app's host>:<port> HELLO_WORLD_API_KEY=<the Control Plane's own caller key> ``
HELLO_WORLD_API_KEY is the key the Control Plane presents to your app, so your app must admit it: add control-plane:<that key> to your app's own SERVICE_API_KEYS. Then recreate both Control Plane containers, the web container and the worker (docker compose up -d --force-recreate web worker in the Control Plane checkout). Lifecycle calls are sent by the worker, and environment values are only read when a container is created, so a restart is not enough.
Step 5 — Emit one event
First, declare the event in your manifest under spec.events.emits[], and declare the topics your app owns under spec.kafka. Each emitted event requires id, topic, transport (outbox here), schema_ref, version, and audience; its topic must match one of your owned_topic_patterns (the cross-field validator enforces this). Use emit_policy: always while you're getting started — subscribed events are listener-gated and stay silent until a webhook or consumer subscribes.
spec:
kafka:
owned_topic_patterns:
- hello-world.greeting.*
consumer_group_pattern: hello-world-{instance_guid}
dead_letter_topic: hello-world.dlq
events:
emits:
- id: hello-world.greeting.created
topic: hello-world.greeting.created # must match an owned_topic_patterns entry
transport: outbox # REQUIRED: outbox | platform_events
description: A greeting was created.
schema_ref: manifest-schemas/events/hello-world-greeting-created.v1.json
version: 1
audience: tenant-private # tenant-private | org-broadcast | platform-broadcast
emit_policy: alwaysfrom egav_app_sdk import enqueue_event
from egav_base.transaction import get_transaction_context
# Inside your create handler — emit within the same transaction as the write.
with get_transaction_context(db):
greeting = service.create_greeting(...) # your write
enqueue_event(
db,
aggregatetype="hello-world.greeting.created", # CloudEvents `type` + manifest emits[].id
aggregateid=str(greeting.guid), # CloudEvents `subject` + Kafka partition key
type="created",
payload={"greeting_guid": str(greeting.guid)}, # GUID-only — no PII in event payloads
org_guid=ctx.org_guid,
app_instance_guid=ctx.tenant_application_instance_guid,
)
# The caller owns the commit; the outbox row commits atomically with the write.What happens to the event
You never hand-build an event dict. enqueue_event inserts one row into the transactional outbox, inside your request's transaction. A separate outbox poller drains committed rows, turns each into a single canonical EventEnvelope — a CloudEvents v1.0 document — and publishes it to Kafka in structured content mode (content-type: application/cloudevents+json), keyed by the CloudEvents subject so a resource's events stay ordered on their partition. The same EventEnvelope model is re-exported from egav_app_sdk.events, so producers, consumers, and webhook receivers all speak one wire format.
Two things to know when you go looking for your first event:
- It's post-commit. The poller only sees the row after your transaction commits. A one-shot script that exits before it commits — or before the poller runs — will appear to "lose" the event. Emit from a long-lived process on the committed path.
- For listener-gated events, use
enqueue_event_if_listenerinstead — it writes the outbox row only when a listener is registered for that event type (and fails open, emitting anyway, if the Control Plane can't be reached to check). This is how services like Comments expose integrator events without producing noise no one consumes.
What you built
In five steps you installed the SDK, wrote and validated a manifest against the platform's own validator, protected a route with authentication, permission, subscription, feature, and quota gates that all fail closed, published to the Control Plane, and emitted a CloudEvents event through the outbox.
Where to go next:
- Manifest reference — every
specsection: resource types and roles, config for the Settings UI, GDPR touch points, audit event types, scheduled tasks. - Auth & the five gates — the trust-tier model, cryptographic tier isolation, and the full status contract.
- Events & the outbox — CloudEvents envelopes, consumers, webhook signing and verification, retries, and dead-lettering.