
How installed apps run inside Auxx.ai at runtime — the surface-based extension system, workflow triggers and blocks, caching strategy, access control, and admin governance.
In part 1 we covered the data model and security model. In part 2 we covered the developer portal. Now lets talk about what happens when an app is installed and running inside the platform.
This is the part where everything comes together — the schema, the SDK, the bundles, the iframe sandbox. An installed app isnt just a row in a database. Its a live extension that registers surfaces, handles events, runs workflow blocks, and renders UI inside the platform.
At the center of the runtime is the app store (apps/web/src/lib/extensions/app-store.ts). Not the marketplace page — the in-memory runtime that manages running app instances.
Apps dont monkey-patch the UI. They register surfaces — typed extension points that the platform renders in the right place. There are five surface types:
| Surface | Where it appears | Example |
|---|---|---|
record-action | Action menu on individual records | "Sync to Salesforce" |
bulk-record-action | Toolbar when multiple records are selected | "Tag selected as VIP" |
record-widget | Custom panel on the record detail page | Live order status from Shopify |
workflow-step | Block in the workflow builder | "Create Jira ticket" |
workflow-trigger | Event source for workflows | "When a new order is placed" |
When an app loads, it sends a set-surfaces PostMessage declaring what it provides. The app store registers these surfaces and the platform queries them when rendering the relevant UI.
Surface predicates control visibility. An app can say "only show this action for ticket records" or "only show this widget when the record has a Shopify order attached." Predicates are evaluated client-side against the current record context — no extra API calls.
Each installed app gets a message client — an object that manages the PostMessage connection to that app's iframe. The app store manages the lifecycle:
set-surfaces, platform indexes themtrigger-surface when user clicks an actionrender-component, opens dialogs, calls server functionsThe message client handles all the PostMessage plumbing — request/response matching via requestId, timeouts, origin validation. Apps communicate through a clean API without knowing any of this exists.
Apps can open modal dialogs via PostMessage. The app store manages a dialog stack — open, close, update content. Dialogs render the app's serialized React tree using the component registry from part 1.
This is where the React-over-PostMessage architecture really pays off. An app can open a dialog, render a form, handle submissions, show loading states, display results — all through the same serialized component protocol. From the user's perspective, the dialog looks and feels native.
This is where apps become truly powerful. An app can define custom workflow blocks and triggers that integrate directly into the visual workflow builder.
An app trigger lets external events start a workflow. Heres the flow:
External service (Shopify, Stripe, etc.)
→ sends webhook to Auxx's public URL
→ AppWebhookHandler routes to the right app + installation + trigger
→ App handler parses the webhook payload into structured triggerData
→ Platform dispatches matching workflows with the trigger data
The routing is the interesting part. When a webhook arrives, AppWebhookHandler looks up which app installation and trigger should handle it. The app's server-side trigger handler then parses the raw webhook payload into a typed triggerData object that the workflow can use.
Trigger inputs are exposed as workflow variables. If a Shopify order trigger outputs orderId, customerEmail, and orderTotal, downstream workflow blocks can reference them as {{triggerNodeId.orderId}}, {{triggerNodeId.customerEmail}}, etc.
Triggers also support filters. A developer can define filter criteria so the trigger only fires for specific event types. For example, a Shopify order trigger might only fire for orders above $100, or a Stripe trigger might only fire for failed payments. Filters are evaluated before dispatching, so workflows dont get clogged with irrelevant events.
Apps define custom workflow steps with typed input/output schemas:
{
id: 'create-jira-ticket',
title: 'Create Jira Ticket',
description: 'Creates a new ticket in Jira',
inputs: {
summary: { type: 'string', required: true },
description: { type: 'string' },
projectKey: { type: 'string', required: true },
priority: { type: 'select', options: ['Low', 'Medium', 'High'] },
},
outputs: {
ticketId: { type: 'string' },
ticketUrl: { type: 'string' },
}
}
At runtime, the block processor (app-workflow-block-processor.ts) handles execution:
{{variable}} syntax for referencing upstream dataBlock format is appId:blockId — namespaced to avoid collisions between apps.
Each block is configurable: timeout, caching behavior, whether it has side effects, retry policy. Blocks marked as side-effect-free can be cached — if the same inputs appear again, the cached result is returned without re-executing. Blocks with side effects always execute fresh.
The execution happens in Lambda, not in the main process. The platform passes context in (installation ID, settings, connection credentials, input values) and gets structured output back. App code cant access other organizations' data, cant crash the main process, and cant hold open long-running connections.
The marketplace page (/app/settings/apps) has two sections:
App cards show the developer name, verification badge (if verified), install status, and a short description. Clicking opens the app detail page with the full overview, screenshots, and install button.
After installation, each app gets a settings dialog with three tabs:
About — app info, description, developer details.
Connections — where the organization provides their credentials. API keys are masked after entry (show last 4 characters). OAuth2 connections launch a full OAuth flow. Custom connection variables show as a key-value form.
Settings — this is the dynamic form generated from the deployment's settings schema. The rendering flow:
AppDeployment.settingsSchemagetSettingsSchema returns the schema for the active deploymentsettings-form-renderer.tsx dynamically builds a form from the schema nodesThe key insight: the form is a convenience. The real validation happens server-side by converting the settings schema to a Zod schema and running the submitted values through it. Even if someone bypasses the form and posts raw JSON, the server validates against the same schema the developer defined in their SDK code.
Two tiers keep the marketplace fast without sacrificing correctness.
appSlugMap — all apps indexed by slug, 1-hour TTL. One database query, indexed in memory. Used for slug → app lookups across the platform.
publishedApps — published apps with developer info and latest deployment, 15-minute TTL. Powers the marketplace browse page.
Both caches are populated on first access and refreshed on TTL expiry. Cache invalidation is event-driven: when an app is published, the publishedApps cache gets invalidated. All marketplace views update within 15 minutes.
These hit the database directly — no cache:
Simple rule: if the data is the same for everyone, cache it. If it's per-org, query it.
Public data (the marketplace catalog) changes infrequently and is identical for every organization. Caching it aggressively saves thousands of duplicate queries. Org-specific data (installations, dev deployments, settings) changes frequently and is access-controlled — caching it would require per-org cache keys, invalidation on every install/uninstall/settings change, and careful access control checks. Not worth the complexity. The database handles it fine.
Three levels of visibility, merged at query time:
Visible to all organizations. Served from the publishedApps Redis cache. This is the public marketplace.
Only the targetOrganizationId can see and install them. Queried directly from the database. This is how developers test their apps before publishing.
Only DeveloperAccountMember entries grant access to the build portal for a given developer account. Separate from marketplace visibility — you can install an app without having access to its developer portal.
getAvailableApps() combines all three sources:
The result is a single list that shows everything the requesting organization can see — public marketplace apps, dev apps being tested on their org, and their current install status — without leaking data from other organizations.
The super-admin panel handles marketplace governance:
Review pipeline. Admins see pending submissions, can approve or reject with reasons. The review UI shows the deployment diff — whats changed since the last published version (new settings fields, updated bundles, changed metadata).
Verified badge. Admins can mark trusted apps as verified. The badge appears on the marketplace card and detail page. Its a signal to organizations that the app has been vetted beyond the standard review.
Auto-approve. Trusted developer accounts can be flagged for auto-approval. Their submissions skip the review queue and go straight to publishable. This is for first-party apps and partners with a track record.
Import/export. Apps can be exported as portable JSON — the full definition including metadata, settings schema, connection definitions, and bundle references. This is useful for dev → staging → prod workflows. Export from dev, import into staging, verify, import into prod.
Deprecation. Old deployments can be marked as deprecated without removing them. Organizations already using the deprecated version keep working. The marketplace shows a deprecation notice and nudges them to update.
Unpublish. Removes an app from the marketplace while preserving all installation data. Existing installations continue working — the app just isnt discoverable by new organizations anymore. This is for situations where an app needs to be pulled but existing users shouldnt be disrupted.
Heres what happens when a user clicks an app action button, end to end:
record-action surfacestrigger-surface PostMessage to the app's iframeopen-dialog + render-component with a serialized React formevent-handler-call with the handler ID and form datarun-server-function PostMessagerender-component → dialog updatesclose-dialog → platform closes the modalAll of that happens in a second or two. The user sees a native-feeling dialog. The app developer wrote normal React and async functions. The security boundary is maintained at every step.
App analytics. We dont track usage metrics per-app yet — install counts, active installations, action invocations, error rates. Developers are flying blind on how their apps are actually being used. A basic analytics dashboard per-app would be high value.
Versioned settings migration. When an app updates its settings schema (adds a field, removes one, changes a type), existing installations keep their old settings. We merge with defaults, which handles new fields. But there's no migration path for renamed or restructured fields. A settings migration function — similar to database migrations — would let developers handle schema evolution cleanly.
Marketplace search. Right now its basic text search on title and description. Weighted search across title, description, category, tags, and developer name would surface better results. Not hard to build, just hasnt been prioritized over core functionality.
Across these three posts, weve covered:
Its about 15 database tables, 6 tRPC routers, a custom React reconciler, a PostMessage protocol, a build CLI, and a Lambda execution layer. Its not simple — but each piece solves a specific problem, and the pieces compose cleanly.
The key architectural bets:
If youre building something similar, the most important thing we learned: start with the deployment model. Get immutability right and everything else — rollbacks, auditing, dev/prod separation, version management — falls into place naturally. Start with mutable deployments and youll spend months retrofitting safety guarantees.
Auxx.ai is open source. PRs welcome.