Applications
Build a app when you need new operations. Build an application when that app becomes the backend for a user-facing workflow with UI, policy, storage, schedules, or other provider bindings.
Gestalt can be used to build applications. At a minimum, an application is a single app package that may include both executable backend code and a browser frontend, using existing primitives for identity, authorization configuration, IndexedDB/Cache/S3, workflows, and agents.
Application Shape
This example application, workspace_review, stores review items in IndexedDB,
writes generated reports to S3, runs a nightly cron through the workflow
provider, and serves a browser UI at /workspace-review from the same app
package.
Use a single app repository with frontend source co-located in the same tree:
workspace-review/
manifest.yaml
package.json
package-lock.json
provider.ts
src/
app.tsx
api.tsThe app manifest owns operations, build phases, and optional local run
commands. For the full manifest schema, see
Provider Manifests.
kind: app
source: github.com/acme/apps/workspace-review
version: 0.1.0
displayName: Workspace Review
description: Review workspace changes and publish nightly summaries.
install:
- [npm, ci]
build:
- command: [npm, run, build]
inputs:
- package.json
- package-lock.json
- src
- provider.ts
run:
- command: [npm, run, dev]
readyTimeout: 120s
- command: [npm, run, dev:provider]
spec:
mcp: true
connections:
default:
mode: none
auth:
type: noneCheck in frontend source and dependency lockfiles. Declare install, build,
and run command lists in the manifest. The build phase writes static assets
to GESTALT_BUILD_STATIC (see Building the Frontend);
Gestalt copies that output into the packaged static/ directory. Engineers
never name static/ in source — it exists only in prepared and release
archives.
Unified app packages are the recommended shape for new applications. List-form
install, build, and run phases are still evolving; explicit commands and
build-tool availability in the preparation environment are required today.
A prepared release archive layout looks like:
manifest.yaml
artifacts/
workspace-review-linux-amd64
static/
index.html
assets/
app-*.js
app-*.css
entrypoint:
artifactPath: artifacts/workspace-review-linux-amd64For static mount and authorization configuration, see Serving and Mounts and Authorization.
Host Services
The deployment config wires the application into host services. Platform identity protects the mounted frontend and HTTP operation routes, the authorization policy controls who may use the application, and the provider bindings expose only the host services the app is allowed to use.
server:
baseUrl: https://gestalt.example.com
encryptionKey: ${GESTALT_ENCRYPTION_KEY}
providers:
identity: oidc
authorization: authz
indexeddb: main
authorization:
models:
default:
resourceTypes:
workspace-review:
relations:
admin:
subjectTypes: [subject]
providers:
identity:
oidc:
source:
package: github.com/valon-technologies/gestalt-providers/auth/oidc
version: 0.0.1-alpha.1
config:
issuerUrl: https://login.example.com
clientId: ${OIDC_CLIENT_ID}
clientSecret:
secret:
provider: default
name: oidc-client-secret
authorization:
authz:
source:
package: github.com/valon-technologies/gestalt-providers/authorization/indexeddb
version: 0.0.1-alpha.1
indexeddb:
provider: main
db: authz
indexeddb:
main:
source:
package: github.com/valon-technologies/gestalt-providers/indexeddb/relationaldb
version: 0.0.1-alpha.1
config:
dsn: ${DATABASE_URL}
s3:
reports:
source:
package: github.com/valon-technologies/gestalt-providers/s3/s3
version: 0.0.1-alpha.1
config: ...
workflow:
local:
source:
package: github.com/valon-technologies/gestalt-providers/workflow/indexeddb
version: 0.0.1-alpha.1
default: true
indexeddb:
provider: main
db: workflow
config:
pollInterval: 1s
apps:
workspace_review:
source: ./workspace-review/manifest.yaml
auth:
provider: server
authorizationPolicy: workspace_review
static:
mount: /workspace-review
indexeddb:
provider: main
db: workspace_review
objectStores:
- items
- reports
s3:
- reports
capabilities:
workflow:
operations:
- events.deliver
workflows:
definitions:
nightly_summary:
provider: local
runAs:
subject:
id: service_account:workspace-review-nightly
steps:
- id: generate_report
app:
name: workspace_review
operation: reports.generateNightly
on:
nightly:
schedule:
cron: "0 3 * * *"
timezone: America/New_YorkFor the full configuration reference, see Config File.
For workflow definitions and activations, see
Providers > Workflow. For the
service_account:workspace-review-nightly subject and runAs, see
Service Accounts.
Application Logic
In app code, open host services from the request context or language SDK. The app does not know database DSNs, S3 credentials, or workflow storage details. Those come from the deployment bindings.
Go
package provider
import (
"context"
"encoding/json"
"net/http"
"time"
gestalt "github.com/valon-technologies/gestalt/sdk/go"
"github.com/valon-technologies/gestalt/sdk/go/client"
)
type Provider struct{}
type SaveItemInput struct {
ID string `json:"id" required:"true"`
Title string `json:"title" required:"true"`
Status string `json:"status" required:"true"`
}
type SaveItemOutput struct {
OK bool `json:"ok"`
}
func (p *Provider) saveItem(
ctx context.Context,
input SaveItemInput,
_ gestalt.Request,
) (gestalt.Response[SaveItemOutput], error) {
db, err := gestalt.IndexedDB(ctx)
if err != nil {
return gestalt.Response[SaveItemOutput]{}, err
}
defer db.Close()
items := db.ObjectStore("items")
if err := items.Put(ctx, gestalt.Record{
"id": input.ID,
"title": input.Title,
"status": input.Status,
"updatedAt": time.Now().UTC().Format(time.RFC3339),
}); err != nil {
return gestalt.Response[SaveItemOutput]{}, err
}
return gestalt.OK(SaveItemOutput{OK: true}), nil
}
type NightlyReportInput struct{}
type NightlyReportOutput struct {
ReportURL string `json:"reportUrl"`
}
func (p *Provider) generateNightly(
ctx context.Context,
input NightlyReportInput,
req gestalt.Request,
) (gestalt.Response[NightlyReportOutput], error) {
db, err := gestalt.IndexedDB(ctx)
if err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
defer db.Close()
s3, err := client.ConnectS3(ctx, "reports")
if err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
body, err := json.Marshal(map[string]string{
"generatedAt": time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
ref := &client.S3ObjectRef{Key: "nightly/" + req.IdempotencyKey + ".json"}
stream, err := s3.WriteObject(ctx, &client.WriteObjectOpen{
Ref: ref,
ContentType: "application/json",
})
if err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
if err := stream.Send(body); err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
if _, err := stream.CloseAndRecv(); err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
access, err := s3.PresignObject(
ctx, client.PresignMethodGet, 900, ref,
&client.S3PresignObjectOptions{ContentType: "application/json"},
)
if err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
if err := db.ObjectStore("reports").Put(ctx, gestalt.Record{
"id": req.IdempotencyKey,
"url": access.Url,
"createdAt": time.Now().UTC().Format(time.RFC3339),
}); err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
workflows, err := gestalt.WorkflowFromContext(ctx)
if err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
_, err = workflows.DeliverEvent(ctx, &client.WorkflowEvent{
Type: "workspace_review.report.generated",
Source: "workspace_review",
Subject: "nightly",
Datacontenttype: "application/json",
Data: map[string]any{"reportUrl": access.Url},
}, nil)
if err != nil {
return gestalt.Response[NightlyReportOutput]{}, err
}
return gestalt.OK(NightlyReportOutput{ReportURL: access.Url}), nil
}
var Router = gestalt.MustRouter(
gestalt.Register(
gestalt.Operation[SaveItemInput, SaveItemOutput]{
ID: "items.save", Method: http.MethodPost,
},
(*Provider).saveItem,
),
gestalt.Register(
gestalt.Operation[NightlyReportInput, NightlyReportOutput]{
ID: "reports.generateNightly", Method: http.MethodPost,
},
(*Provider).generateNightly,
),
)Calling Another App From Your Application
Use the generated App client when executable app code needs to call another
configured app as part of the same request. Open it with the request-scoped
helper for your language (gestalt.AppFromContext(ctx) in Go, request.app()
in Python and TypeScript, App::connect().await?.with_context(...) in Rust).
Hosted apps may call any other configured app without declaring a deploy-time
dependency graph. The client sends the current request context to the host, which
preserves the original subject, credential context, and permission ceiling for
the nested call unless you pass per-request execution options.
apps:
triage:
source: ./apps/triage/manifest.yaml
github:
source: ./apps/github/manifest.yamlGo
import (
"context"
gestalt "github.com/valon-technologies/gestalt/sdk/go"
"github.com/valon-technologies/gestalt/sdk/go/client"
)
func loadLinkedIssue(ctx context.Context, request gestalt.Request, issueNumber int) (map[string]any, error) {
app, err := gestalt.AppFromContext(ctx)
if err != nil {
return nil, err
}
// Invoke decodes the standard JSON operation envelope; payload
// failures return *client.InvokeError. Use InvokeRaw for the raw
// status, headers, and body.
return client.JSONResultAs[map[string]any](app.Invoke(
ctx,
"github",
"issues.get",
map[string]any{
"owner": "acme",
"repo": "roadmap",
"number": issueNumber,
},
&client.AppInvokeOptions{
Connection: "default",
IdempotencyKey: request.IdempotencyKey,
RunAs: &client.SubjectContext{
ID: "service_account:nightly-sync",
},
CredentialMode: "none",
},
))
}Pass connection, instance, credentialMode, runAs, or an idempotency key
when the target app should use a specific connected account, provider instance,
delegated service-account identity, credential resolution mode, or retry
boundary. Agent tool calls and workflow steps still authorize their own targets;
they cannot override authority with request runAs. For raw GraphQL surfaces, use InvokeGraphQL, invoke_graphql, or invokeGraphQL in
the same SDK client; they return the raw result, and the SDK’s
decodeGraphQLResult helpers apply the same envelope decoding plus GraphQL
errors handling.
Building the Frontend
Frontend assets live in the same app repo as the provider. Gestalt does not
serve TypeScript or JSX source at runtime — only the prepared static/ bundle.
Relative asset references
Use relative URLs in HTML, CSS, and JS (./assets/app.js, not /assets/app.js).
Gestalt injects a <base href="/{mount}/"> tag when serving mounted apps so
relative references resolve under the mount path. Client routers should derive
their base from document.baseURI rather than hard-coding /.
During build, Gestalt sets GESTALT_BUILD_STATIC to an absolute path under
.gestalt/build-static/ in the manifest directory. Your build script must write
index.html and assets there. Gestalt verifies index.html exists after the
build phase and stages the directory into static/ in release archives.
Vite
Use the Gestalt Vite plugin for base-path and dev-server defaults:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import gestalt from "@valon-technologies/gestalt/vite";
export default defineConfig({
plugins: [react(), gestalt()],
build: {
outDir: process.env.GESTALT_BUILD_STATIC,
emptyOutDir: true,
},
});gestalt() sets base to a relative path, configures the dev server for
GESTALT_DEV_* when gestaltd proxies local run commands, and proxies /api/v1
to GESTALT_BASE_URL for two-origin local dev.
Manual equivalent when not using the plugin:
export default defineConfig({
base: "./",
server: {
host: "127.0.0.1",
port: Number(process.env.GESTALT_DEV_PORT),
strictPort: true,
},
build: {
outDir: process.env.GESTALT_BUILD_STATIC,
emptyOutDir: true,
},
});webpack
Set output.publicPath to "" (empty string) so chunk URLs stay relative.
Unsupported frameworks
Frameworks that emit absolute asset URLs only — including default Next.js and
Astro production builds — are not compatible with Gestalt static mounts. Use a
static-export mode with relative URLs, or a bundler that supports relative
publicPath / base.
Serving and Mounts
Deployment config mounts prepared static assets with apps.<name>.static:
apps:
workspace_review:
source: ./workspace-review/manifest.yaml
static:
mount: /workspace-review| Field | Description |
|---|---|
static.mount | Public URL prefix. Defaults to /{app name} when omitted. |
static.public | When true, serve the static bundle without login. Defaults to false. |
static.theme | Optional deployer workaround: paths to a theme stylesheet and assets directory injected before the static handler. |
Only one app may mount at /.
Gestalt serves static files from the prepared static/ directory, applies SPA
fallback (unknown paths under the mount return index.html), and injects
<base href> into HTML responses when the document does not already define one.
Theme endpoints (/theme.css, /theme/*) are handled before the static file
handler when static.theme is configured.
Authorization
Mounted app static assets support three access tiers:
| Tier | Configuration | Behavior |
|---|---|---|
| Public | static.public: true | No login required to load HTML, CSS, JS, or theme assets. API calls still require a session. |
| Authenticated | Default (static.public omitted or false) | Browser login required before any bytes are served from the mount. |
| Authorized | authorizationPolicy set (or app name as resource type) | After login, Gestalt checks relationships before serving bytes. Unauthorized requests receive a login redirect or 403 without leaking asset contents. |
When static.public: true, authorization policy and relationships do not gate static serving. They still apply to /api/v1/{app}/{operation} routes.
Example public docs mount:
apps:
docs:
static:
mount: /docs
public: trueGrant access for authenticated and authorized tiers with static relationships and optional default roles:
authorization:
models:
default:
resourceTypes:
workspace_review:
defaultRole: viewer
relations:
viewer:
subjectTypes: [subject]
admin:
subjectTypes: [subject]
relationships:
- subject:
type: subject
id: user:alice
relation: viewer
resource:
type: workspace_review
id: workspace_reviewauthorization.models.<model>.resourceTypes.<app>.defaultRole names the role
assigned when the subject has a relationship to the app resource but no more
specific relation matches. Operation-level role allowlists in
apps.<name>.allowedOperations still apply to API calls; they do not create
separate static route policies.
Local development uses the same gate: gestaltd serve proxies run commands
only after the app passes the configured authorization policy.
Lightweight Frontend
The frontend can stay small because Gestalt already handles sessions, route authorization, and static asset serving. A mounted UI calls the app operations on the same origin:
export async function saveItem(item) {
const response = await fetch("/api/v1/workspace_review/items.save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item),
});
if (!response.ok) {
throw new Error(`Save failed: ${response.status}`);
}
return response.json();
}Use Serving and Mounts and
Authorization for mount and access configuration. Check
frontend source and lockfiles into the app repo; Gestalt prepares static output
during lock, sync, and package — do not commit .gestalt/build-static/ or
packaged static/ trees.
Configuring Webhooks
Applications can expose product-specific webhooks through the same app that
backs the UI, scheduled work, and host-service bindings. Declare the
hosted HTTP route in the
application app manifest, and implement webhooks.github in the SDK
language of choice like any other application operation. The following example
configures the public webhook URL as
/api/v1/workspace_review/webhooks/github.
kind: app
source: github.com/acme/apps/workspace-review
version: 0.1.0
displayName: Workspace Review
description: Review workspace changes and publish nightly summaries.
spec:
mcp: true
connections:
default:
mode: none
auth:
type: none
securitySchemes:
github_webhook:
type: hmac
secret:
env: GITHUB_WEBHOOK_SECRET
signatureHeader: X-Hub-Signature-256
signaturePrefix: sha256=
payloadTemplate: "{raw_body}"
http:
github_webhook:
path: /webhooks/github
method: POST
credentialMode: none
security: github_webhook
target: webhooks.github
requestBody:
required: true
content:
application/json: {}Local Development
Run the unified app locally from the app directory with supporting provider bindings in a development config file:
gestaltd provider validate --path ./workspace-review --config ./dev.yaml
gestaltd serve ./workspace-review --config ./dev.yamlWhen the app manifest declares run: commands and config sets
apps.<name>.static.mount, gestaltd classifies the app for local development:
- Full-stack dev — a
runcommand opens the provider socket onGESTALT_PROVIDER_SOCKETand (optionally) anotherruncommand binds a frontend dev server toGESTALT_DEV_PORT. - Frontend-only dev — only the frontend binds
GESTALT_DEV_PORT; gestaltd serves API routes from pinned artifacts or skips backend registration after a short readiness grace period.
gestaltd appends these environment variables to each run command (manifest
env entries are applied first):
| Variable | Purpose |
|---|---|
GESTALT_DEV | 1 while gestaltd manages the dev process |
GESTALT_DEV_PORT | Ephemeral 127.0.0.1 port the frontend dev server must bind |
GESTALT_DEV_BASE_PATH | Mount path from apps.<name>.static.mount |
GESTALT_BASE_URL | Public gestaltd origin; consumed by gestalt() for /api/v1 proxying |
GESTALT_PROVIDER_SOCKET | Unix socket for the app provider (full-stack apps only) |
The dev supervisor probes frontend readiness on GESTALT_DEV_PORT, restarts
crashed commands with exponential backoff, and allows one dev binder per app
name.
Package script recipe
{
"scripts": {
"dev": "vite",
"dev:provider": "tsx ./provider.ts",
"build": "vite build"
}
}run:
- command: [npm, run, dev]
readyTimeout: 120s
- command: [npm, run, dev:provider]Map framework dev servers to the Gestalt contract:
| Tool | Port | Base path |
|---|---|---|
Vite (gestalt() plugin) | process.env.GESTALT_DEV_PORT | handled by plugin |
| Vite (manual) | --port $GESTALT_DEV_PORT --host 127.0.0.1 | base: './' in config |
Angular (ng serve) | --port $GESTALT_DEV_PORT --host 127.0.0.1 | --serve-path $GESTALT_DEV_BASE_PATH |
| webpack-dev-server | port: Number(process.env.GESTALT_DEV_PORT) | devMiddleware.publicPath: process.env.GESTALT_DEV_BASE_PATH + '/' |
| Parcel | --port $GESTALT_DEV_PORT --host 127.0.0.1 | --public-url $GESTALT_DEV_BASE_PATH/ |
For layered config, remote attach, and PATH overrides, see App > Testing locally and CLI > Local Frontend Development.