Skip to main content

Documentation

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.

requirements.txttext
# 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 packages

The 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.

bash
# 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 --version

Step 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:

backend/app.manifest.yamlyaml
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: /health
bash
egav-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 one authz: block and the audit catalogue is not audit.catalog. The strict schema uses spec.resource_types / spec.cp_permissions / spec.cp_roles, spec.audit.event_types, and top-level spec.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:

backend/main.pypython
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:

  1. Authentication — is this a valid, unexpired, non-revoked portal token?
  2. Permission — may this user perform this action on this resource?
  3. Subscription — is the org's subscription active?
  4. Feature — is this capability in the org's plan?
  5. 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:

backend/routes.pypython
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:

StatusMeaning
401Missing, invalid, expired, or revoked token
402Subscription lapsed (payload includes an actionable renew/upgrade link)
403Permission denied, feature not in plan, or wrong trust tier
429Quota exceeded
503A dependency (AuthZ / Control Plane / Redis) is unreachable — fail closed

Two accuracy notes for real wiring:

  • RequireAuthz binds to a declared resource. Its first argument is a spec.resource_types code — declare the resource you're guarding (with its actions) in your manifest before referencing it here.
  • Order matters. RequireAuthz resolves the portal identity and writes it to request.state.identity; the three entitlement gates read org_guid from there. Keep RequireAuthz first in the dependencies list 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.

bash
# 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:

bash
# 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"
powershell
# 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.

backend/app.manifest.yamlyaml
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: always
backend/routes.pypython
from 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_listener instead — 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 spec section: 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.