Developer Documentation
A Veltrix app manages one security tool's configuration as code. Instead of clicking through a SIEM or EDR console, operators author configuration on a canvas, and the app drives it through the Security-as-Code pipeline: validate → deploy → rollback → health-check → drift-detect → status. The result is versioned, reviewable, auto-rolled-back configuration for any tool you can talk to over an API or SSH.
Apps are open source and live in the veltrix-apps repository. You build them against the typed contract in @veltrixsecops/app-sdk and scaffold, validate, and live-develop them with the Veltrix CLI. This guide is the conceptual map; the SDK and CLI references are the exact API.
An app is a self-contained package that teaches the platform how to manage a tool. It bundles everything needed to treat that tool's configuration as code:
canvas.yaml) and default values (defaults.yaml) operators fill in.onInstall / onUninstall and friends./api/apps/<app-id>.Handlers run on the server in a hardened Node process. They receive everything they need through a typed context object — the canvas data, the target component, credentials, connectivity, and a read-only platform data API — and never reach into the platform's database or internals directly.
Every app must define at least one configuration type with a full set of pipeline handlers. This is what makes configuration reviewable, approvable, deployable, and continuously enforced against drift. An app without a pipeline is not a valid app.
Every app follows one predictable folder structure. veltrix init <app-id> scaffolds it, veltrix validate and repo CI warn on deviations, and the SDK exports it as APP_LAYOUT / conventionalPaths().
apps/my-security-tool/
├── manifest.yaml # App contract (required)
├── package.json # SDK + tooling devDependencies
├── tsconfig.json
├── README.md # What the app manages + credential setup
├── config-types/<configTypeId>/ # THE unit of extension — everything for
│ ├── canvas.yaml # one configuration type in one folder:
│ ├── defaults.yaml # form schema, default values, and the
│ ├── validate.ts # six pipeline handlers
│ ├── deploy.ts
│ ├── rollback.ts
│ ├── healthCheck.ts
│ ├── driftDetect.ts
│ ├── getStatus.ts
│ └── __tests__/ # Tests next to the code they cover
├── lib/ # Shared app code (API clients, parsers)
├── hooks/ # Lifecycle hooks (camelCase): onInstall.ts, ...
├── migrations/ # SQL migrations (optional, tablePrefix enforced)
├── server/index.ts # Route module (AppRouteContext)
├── client/index.tsx + client/pages/ # Custom UI pages (optional)
└── assets/ # Icons, logos (optional)
The config-types/<configTypeId>/ directory is the unit of extension. To manage another kind of configuration (say, Splunk indexes vs roles), drop in one new folder with a canvas.yaml, defaults.yaml, and the six handlers, then add one entry under pipeline.configurationTypes in the manifest. Nothing else moves.
Manifest references to code are extensionless and resolved like require() — .ts in local development, compiled .js in a released package. So config-types/indexes/deploy resolves to config-types/indexes/deploy.ts during development.
The manifest is your app's contract with the platform. Here is an annotated walkthrough of the key sections (drawn from the real splunk-enterprise app):
id: splunk-enterprise # Unique slug — lowercase, hyphens; must equal the directory name
name: Splunk Enterprise
version: 1.2.0 # semver — CI requires a bump on every app change
vendor: Veltrix
description: Manage Splunk Enterprise configurations as code.
category: SIEM # SIEM, EDR, SOAR, IAM, NETWORK, CLOUD, COMPLIANCE, Endpoint Management, Threat Intelligence, CUSTOM
license: Apache-2.0
platform:
minVersion: "1.0.0" # Minimum platform version this app supports
# -- Permissions -------------------------------------------------------------
permissions:
platform: # What the app NEEDS from the platform
- configuration-canvas:read
- configuration-canvas:write
- component:read
- credential:read
app: # Permissions the app EXPOSES (admins assign to roles)
- resource: indexes
actions: [read, write, delete]
description: Manage Splunk index configurations
# -- Pipeline (MANDATORY) ----------------------------------------------------
pipeline:
configurationTypes:
- id: indexes # Unique within the app; the config-types/<id>/ folder name
name: Index Configuration
canvasTemplate: config-types/indexes/canvas.yaml
defaultConfig: config-types/indexes/defaults.yaml
handlers: # Extensionless paths to the six handlers
validate: config-types/indexes/validate
deploy: config-types/indexes/deploy
rollback: config-types/indexes/rollback
healthCheck: config-types/indexes/healthCheck
driftDetect: config-types/indexes/driftDetect # optional
getStatus: config-types/indexes/getStatus
options: config-types/indexes/options # optional — powers remote-select live-picker fields
targets:
componentTypes: [indexer, cluster-manager] # Which components to deploy to
requiresCredential: true # Needs tool credentials
requiresConnectivity: true # Needs SSH/API access to components
pipelineEvents: # Events the app emits (other apps can subscribe)
- canvas:deployed
- drift:detected
# -- Database (optional) -----------------------------------------------------
database:
migrations: migrations # migrations/*.sql at the app root
tablePrefix: splunk_ # Every app table MUST be prefixed (unique across apps)
isolation: schema # 'shared' | 'schema' (default for marketplace apps) | 'database' | 'external'
# -- Server ------------------------------------------------------------------
server:
entry: server/index
routes:
prefix: /api/apps/splunk-enterprise # All app routes namespaced here
# -- Client UI (optional) ----------------------------------------------------
# The platform renders the chrome (breadcrumb, app header, nav, permission
# gating, loading + error states); your component renders only the page body.
client:
entry: client/index
navLayout: sidebar # 'tabs' (default) | 'sidebar' (scales to many config types)
pages:
- path: /indexes
component: IndexesPage
label: Indexes
description: Manage index definitions
icon: database
nav: sidebar # sidebar | tab | hidden (supersedes the boolean `sidebar`)
order: 1 # deterministic ordering (ascending; ties break on label)
layout: standard # standard | full-bleed | canvas
requiresPermission: # hides the page AND its nav entry unless held
resource: indexes
action: read
# -- Connectivity, connection onboarding & branding (optional) ---------------
connectivity:
testHandler: handlers/testConnection # backs the "Test" button on a Connection
connection: # optional one-click consent onboarding (e.g. Microsoft admin consent)
onboarding:
provider: entra-admin-consent
label: Connect Microsoft Defender
branding:
primaryColor: "#FC0000" # applied by the platform in the app navbar + scoped CSS vars
logo: ./assets/logo.svg
# -- Lifecycle hooks & settings (optional) -----------------------------------
hooks:
onInstall: hooks/onInstall
onUninstall: hooks/onUninstall
# also: onEnable, onDisable, onUpgrade, onWebhook (inbound webhooks), onEvent (message-bus events)
events: # platform events the app SUBSCRIBES to (distinct from pipelineEvents it emits)
- deployment:completed
settings: # Admin-configurable per tenant
- key: splunk_api_port
type: number # 'string' | 'number' | 'boolean' | 'select' (select needs options: [{label,value}])
label: Splunk Management Port
default: 8089
required: false
See the SDK Reference for the full AppManifest type, including every field on permissions, pipeline.configurationTypes, targets, and settings.
Every configuration type implements up to six handlers. Each is a default-exported async function that receives a typed context and returns a typed result. Import the types from @veltrixsecops/app-sdk.
See the handlers in motion. Every app ships an auto-generated request-flow diagram tracing how each operation — Deploy, Drift, Rollback, Test connection — routes through the pipeline, the app, and the vendor API to completion, including the RBAC permission check, the human approval gate, environment-scoped credentials, and drift reconciliation. Step through them interactively on the App Dataflows site, or read the DATAFLOW.md in any app folder.
| Handler | When it runs | Purpose |
|---|---|---|
validate |
Before approval | Block invalid config with field-level errors; surface warnings |
deploy |
On approved deployment | Push config to the target component; return rollback data |
rollback |
On failure or manual revert | Restore the previous configuration |
healthCheck |
During/after deployment | Verify the tool is healthy; a low score triggers auto-rollback |
driftDetect |
On a schedule (optional) | Compare live state vs deployed config; report differences |
getStatus |
On demand | Report what is currently deployed and per-component health |
One real signature per handler — note the distinct context and result type for each:
import type {
PipelineContext, ValidationResult,
DeployContext, DeployResult,
RollbackContext, RollbackResult,
HealthCheckContext, HealthCheckResult,
DriftContext, DriftResult,
ConfigStatus,
} from '@veltrixsecops/app-sdk'
// 1. validate — runs before approval; return field-level errors to block
export default async function validate(ctx: PipelineContext): Promise<ValidationResult> { /* ... */ }
// 2. deploy — push config to ctx.component; return rollbackData for later
export default async function deploy(ctx: DeployContext): Promise<DeployResult> { /* ... */ }
// 3. rollback — revert using ctx.rollbackData / ctx.targetVersion
export default async function rollback(ctx: RollbackContext): Promise<RollbackResult> { /* ... */ }
// 4. healthCheck — return { healthy, score, checks[] }; low score auto-rolls-back
export default async function healthCheck(ctx: HealthCheckContext): Promise<HealthCheckResult> { /* ... */ }
// 5. driftDetect — compare ctx.deployedConfig vs live; return { hasDrift, diffs[] }
export default async function driftDetect(ctx: DriftContext): Promise<DriftResult> { /* ... */ }
// 6. getStatus — report current deployment + per-component health
export default async function getStatus(ctx: PipelineContext): Promise<ConfigStatus> { /* ... */ }
A minimal but complete validate handler looks like this:
import type { PipelineContext, ValidationResult } from '@veltrixsecops/app-sdk'
export default async function validate(ctx: PipelineContext): Promise<ValidationResult> {
const errors: ValidationResult['errors'] = []
const warnings: ValidationResult['warnings'] = []
for (const section of ctx.canvas.sections) {
const name = section.fields.name as string | undefined
if (!name) {
errors.push({ field: `${section.name}.name`, message: 'Name is required', code: 'required' })
}
}
return { valid: errors.length === 0, errors, warnings }
}
Prefer the typed define* helpers from @veltrixsecops/app-sdk/pipeline for extra inference — see the SDK Reference.
Apps get UI without owning the frame. The design principle is predictable shell, flexible body: the platform renders the chrome around every app page — the breadcrumb (Apps / <App> / <Page>), the app header, navigation, permission gating, loading skeletons, and the error boundary — and your component renders only the page body, composed from @veltrixsecops/app-sdk/ui. Apps cannot alter the chrome or the brand palette, and in exchange they get full freedom inside the frame and a UI that is consistent across every vendor's app.
You declare pages under client.pages[] in the manifest. Each entry maps a route to an exported component and tells the platform how to place and frame it:
| Field | Meaning |
|---|---|
path | Route beneath the app (must start with /); mounted under /apps/<app-id> |
component | Component name exported from the client entry |
label, description?, icon? | Nav label, optional subtitle, and platform icon (falls back to the app icon) |
nav | sidebar | tab | hidden — supersedes the legacy boolean sidebar (still accepted, emits a deprecation warning) |
parent | Required for nav: tab; must match another page's path |
group, order | Optional sidebar section label; deterministic ordering (ascending, ties break on label) |
layout | standard | full-bleed | canvas — full-bleed is the escape hatch for custom canvases; canvas gets Configuration Canvas chrome |
requiresPermission | Hides the page and its nav entry unless the user holds { resource, action }; CI rejects a permission not declared in permissions.app |
client:
entry: client/index
pages:
- path: / # /apps/my-app
component: Dashboard
label: Overview
nav: sidebar
order: 1
layout: standard
- path: /configs # a tab inside Overview, permission-gated
component: ConfigsTab
label: Configurations
nav: tab
parent: /
requiresPermission: { resource: "my-app:configs", action: read }
Both halves are live today: the platform reads client.pages[], places nav entries, and CI enforces the permission and field rules; and the CLI compiles your client/index.tsx into a hermetic bundle that mounts the page body inside the shell (React and every @veltrixsecops/app-sdk import resolve from the host at runtime). Build the body from @veltrixsecops/app-sdk/ui.
Two seams reach a tool: the transport (how you reach the host — ctx.connectivityProvider, below) and the credential (how you authenticate). Most apps ship a /connections client page (nav group: settings) that renders the shared ConnectionsManager, plus a manifest connectivity.testHandler that backs the "Test" button:
// client/pages/ConnectionsPage.tsx
import { ConnectionsManager } from '@veltrixsecops/app-sdk/connections'
export default () => <ConnectionsManager appId="my-app" appName="My App" usernameLabel="Client ID" tokenLabel="Secret" />
// handlers/testConnection.ts — one lightweight authenticated probe
import { defineConnectionTester } from '@veltrixsecops/app-sdk/pipeline'
export default defineConnectionTester(async (ctx) => ({ ok: await probe(ctx), details: [] }))
From a server route, reach the connected system with a decrypted secret — without re-implementing the platform's crypto — via ctx.resolveConnection(customerId, credentialId) (returns { endpoint, username, password, apiToken, certificate }; server-side only, never sent to the client). Connections onboarded through the platform's one-click consent flow (declared under manifest connection.onboarding) carry no secret — handlers instead mint an app-only token with ctx.identity.getAccessToken(...).
A config field can also offer a live picker sourced from the tool — declare it remote-select / remote-multiselect with an optionsSource in canvas.yaml, and add an options provider at config-types/<type>/options.ts (wired via handlers.options). The platform resolves the connection and runs it in-process, so users pick by name while a stable id is stored.
The platform provides a shared remote connectivity system that every app inherits automatically. You do not build your own SSH, VPN, or tunnel handling. When a configuration type sets requiresConnectivity: true, handlers receive the tenant's configured provider on ctx.connectivityProvider.
export default async function deploy(ctx: DeployContext): Promise<DeployResult> {
if (ctx.connectivityProvider) {
const { providerType, config } = ctx.connectivityProvider
switch (providerType) {
case 'tailscale': /* config.tailnet, config.apiKey — reach the device via Tailscale */ break
case 'ssh': /* config.username, config.privateKey, config.port */ break
case 'wireguard': /* config.endpoint, config.privateKey */ break
// ...other providers
}
}
return { success: true, message: `Deployed to ${ctx.component.hostname}` }
}
Admins configure one of nine providers in the platform Settings UI; the app just reads ctx.connectivityProvider:
| Provider | Type | providerType |
|---|---|---|
| Tailscale | Mesh VPN | tailscale |
| SSH Keys | Traditional | ssh |
| WireGuard | Mesh VPN | wireguard |
| Cloudflare Tunnel | Zero Trust | cloudflare_tunnel |
| ZeroTier | Mesh VPN | zerotier |
| Nebula | Mesh VPN | nebula |
| OpenVPN | Traditional | openvpn |
| AWS SSM | Cloud Native | aws_ssm |
| HashiCorp Boundary | Zero Trust | boundary |
A legacy per-component ctx.connectivity (SSH command / HTTPS URL / Tailscale IP) is also provided for backward compatibility. Prefer ctx.connectivityProvider for new apps.
Apps ship through a reviewed, automated pipeline. There is no manual publishing step:
main in the veltrix-apps repo describing what the app does, which tools it integrates, and how to test it.veltrix validate runs locally: manifest schema, handler completeness, semver bump, no executables, package size cap, import boundaries, TypeScript compile, and a secret scan..github/CODEOWNERS).<app-id>-v<version> with the ZIP and its SHA-256 as assets.The marketplace catalog consumed by Veltrix instances is published at:
https://captivatortechnologies.github.io/veltrix-apps/catalog.json
Tenants install an app from the Apps page — from the catalog, or by URL from a release asset:
https://github.com/captivatortechnologies/veltrix-apps/releases/download/<app-id>-v<version>/<app-id>.zip
Bump version in the app's manifest.yaml (semver: patch for fixes, minor for new capability). CI rejects app changes that do not bump the version, and releases are immutable — a version tag is never overwritten.
Ready for the exact contracts and tooling?
@veltrixsecops/app-sdk.init, validate, package, and the sandbox dev loop.