Calling AI Employees from App Code
An App Builder app is real full-stack code, so at some point you (or the AI agent building it) write the server route that actually calls an AI Employee. This page is the developer reference for that surface: how the app's back end reaches your AI Employees (AIEs) and workflows, what it is and isn't allowed to call, how to handle human-in-the-loop from app code, how to read and write the app's own database, and the guard that runs at publish time. If you're new to App Builder, start with the overview and The App Editor; this page assumes you're editing code.
Every app starts from the Ema starter template — a Vite + React front end and a Hono (Node.js) back end. The template ships with the AI Employee client, the database SDK, the auth middleware, and the route patterns already wired in. These are protected files; you build on top of them rather than replacing them.
One outbound path. A published app's back end has exactly one sanctioned outbound HTTP destination: Ema's gateway, reached through the built-in client. Every other host is blocked — first by a publish-time scan, and ultimately by a kernel-level network policy on the published pod. This is the core security property of the platform, not a guideline you can opt out of.
The gateway client
All AI Employee and workflow calls go through a single client class, EmuClient, defined in the template at server/lib/api/emu-client.ts. It is the only sanctioned outbound HTTP path for a frontier app. Each method routes through the frontier apps gateway, forwarding the signed-in user's JWT in the Authorization header so the workflow service sees the actual user — not a service account.
You construct it per request from the user's token, which the auth middleware places on the Hono context. Never cache an instance across requests; the token is per-user.
import { EmuClient } from '../lib/api/emu-client.js'
import { withApiHandler } from '../lib/api/with-api-handler.js'
routes.post('/extract', withApiHandler({ module: 'extract' }, async (c, hctx) => {
const body = await c.req.json()
const client = new EmuClient(hctx.user.token)
// The run body MUST be { input_params, session_id? }.
// A wrong key (e.g. `input`) is silently ignored by the workflow service.
const run = await client.runWorkflow(WORKFLOW_ID, { input_params: body })
return c.json(run)
}))
For routes that serve the browser SPA, prefer the createEmuClient(c, token) helper from with-api-handler.ts over the bare constructor. In local dev it wires up a callback that forwards a refreshed per-app token back to the browser via Set-Cookie, so an expired token is re-minted once instead of on every subsequent request.
Which AI Employee a route calls
App code never hardcodes a workflow ID. An app declares named slots in its catalog.yaml — for example AIE_RESUME_PARSER — and reads them from the environment at runtime (process.env.AIE_RESUME_PARSER). In the builder you bind each slot to a concrete workflow in your tenant, and the binding is injected as a pod environment variable at publish time. This is what lets the same app deploy unchanged across tenants and environments. See Configuration and bindings for the binding UI and the overview for the slot model.
The workflow ID you bind comes from the workflow's URL in the AI Employee builder. To discover a workflow's input shape and scaffold a typed route, hook, and form, use the in-editor aie-integrate flow rather than guessing.
Running a workflow
runWorkflow starts a run and returns immediately — the run keeps executing on the workflow service. The request and response shapes are pinned in the client's TypeScript types:
interface RunWorkflowRequest {
input_params: Record<string, unknown> // required — the workflow service reads this key
session_id?: string // optional chat-session thread
}
interface RunWorkflowResponse {
id: string // run ID — pass to getRun / streamRun / cancelRun
status: string // 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
workflow_id: string
}
The input_params key is load-bearing. The workflow service reads the run inputs from input_params at the top level of the body. Passing a bare object, or wrapping it under input, results in an empty trigger payload — the run still starts, then fails far downstream when an agent node reads a variable that was never set. The shape of input_params itself comes from the workflow's start node; discover it with the aie-integrate flow, don't guess.
Polling for the result
The simplest way to consume a run is to poll getRun until the status is terminal:
let run = await client.runWorkflow(id, { input_params })
while (run.status === 'pending' || run.status === 'running') {
await new Promise((r) => setTimeout(r, 1000))
run = await client.getRun(run.id)
}
// run.status is now 'completed' | 'failed' | 'cancelled'
// run.output holds the result when completed.
getRun returns the run's current state, including output once it completes. For per-step progress (each step's status, node label, and output), poll getRunSteps(run.id) between getRun calls. To watch a run live instead of polling, streamRun(run.id) returns a server-sent-events ReadableStream you parse yourself; reach for it only when polling isn't enough, since it doubles the surface you have to handle.
listRuns(workflowId, { page, page_size }) returns a paged history of a workflow's runs — the natural data source for an in-app "recent runs" view. cancelRun(runId) stops a running run.
Error handling
The client throws two typed errors. Handle both and propagate the upstream detail — never collapse them into a generic 500, because the status and body carry the workflow service's validation messages and the gateway's allowlist hits.
import { AuthExpiredError, EmuError } from '../lib/api/emu-client.js'
try {
const run = await client.runWorkflow(id, { input_params })
} catch (err) {
if (err instanceof AuthExpiredError) {
// Token expired or invalid (upstream 401). Surface 401 to the browser;
// the SPA's interceptor handles re-authentication.
return c.json({ error: { code: 'AUTH_EXPIRED' } }, 401)
}
if (err instanceof EmuError) {
// Any other non-2xx. Carries .status and .body from upstream — e.g. a 422
// validation error from workflow, or a 403 path-not-allowed from the gateway.
return c.json(
{ error: { code: 'EMU_UPSTREAM', message: err.message, status: err.status, body: err.body } },
502,
)
}
throw err
}
The path allowlist
The gateway exposes only a small allowlist of paths. Anything outside it returns 403 path not permitted — there is no way to reach the rest of the workflow or chat API surface from app code. The allowlist is enforced in the gateway itself (two independent handlers, one for /api/v1/workflow/* and one for /api/v1/chat/*), so it holds regardless of what the app's code attempts.
These are every path the gateway accepts, with the EmuClient method that wraps each one:
| Method | Path | Client method | Purpose |
|---|---|---|---|
| POST | /api/v1/workflow/workflows/{id}/run | runWorkflow | Start a run; returns { id, status, workflow_id }. |
| POST | /api/v1/workflow/workflows/{id}/dry-run | dryRunWorkflow | Validate inputs with no side effects. |
| GET | /api/v1/workflow/workflows/{id}/runs | listRuns | Paged run history for a workflow. |
| GET | /api/v1/workflow/runs/{run_id} | getRun | Poll status and output. |
| GET | /api/v1/workflow/runs/{run_id}/steps | getRunSteps | Per-step progress. |
| GET | /api/v1/workflow/runs/{run_id}/stream | streamRun | Server-sent-events stream of run progress. |
| POST | /api/v1/workflow/runs/{run_id}/cancel | cancelRun | Cancel a running run. |
| GET | /api/v1/workflow/{id}/rows/{row_id}/hitl | getRowHITL | Fetch the human-in-the-loop request for a paused dashboard row. |
| GET | /api/v1/workflow/hitl/{request_id} | getHITLRequest | Fetch a single human-in-the-loop request by ID. |
| POST | /api/v1/workflow/hitl/{request_id}/respond | respondHITL | Submit a human response and resume a paused run. |
| POST | /api/v1/workflow/hitl/{request_id}/cancel | cancelHITL | Abandon a paused human-in-the-loop request. |
| POST | /api/v1/chat/sessions/{id}/events | submitChatEvent | Log a custom event on a chat session the caller owns. |
| GET | /api/v1/chat/sessions/{id}/events | listChatEvents | List custom events on a chat session the caller owns. |
The gateway also matches GET /api/v1/workflow/hitl/pending through the same single-segment rule that backs getHITLRequest; the listPendingHITL method calls it to drive a review queue (see below).
What is deliberately not reachable, each blocked on purpose:
- Listing or mutating workflows themselves.
- Listing or creating dashboard tables and rows.
- Claiming a human-in-the-loop request (
POST /hitl/{id}/claim) — the model is "first response wins" until a multi-reviewer use case appears. - Any admin or apex-only endpoint.
If a route in your app calls a path that isn't in the table, the gateway answers 403 before the request ever reaches the workflow or chat service. Adding a path is a platform change to the gateway's allowlist, not something an app can route around.
Human-in-the-loop from app code
Some workflows pause mid-run waiting for a person to fill in a form — for example, "review this draft before it sends." When that happens, polling getRun returns a non-terminal status until the human responds. Two patterns close the loop from a frontier app.
Starting from a known row. If you have the workflow ID and the dashboard row ID, fetch the pending request, render its form in your UI, and post the response back:
// 1. The DAG paused on a row's HITL node. Fetch the prompt + form schema.
const hitl = await client.getRowHITL(workflowId, rowId)
// hitl.prompt is the question for the human; hitl.form_schema is a JSON
// Schema describing the response fields.
// 2. After the human fills the form in your UI, POST the values back.
// The response shape must match hitl.form_schema.
await client.respondHITL(hitl.request_id, { approved: true, note: 'lgtm' })
// The workflow service resumes the run. Keep polling getRun to watch it
// through to completion.
// 2b. To reject instead of approve:
await client.cancelHITL(hitl.request_id)
A review queue. When you don't have a row in hand, list every pending request in the tenant and render them as a worklist:
const { items } = await client.listPendingHITL()
// items: HITLRequest[] — each carries request_type, prompt, form_schema,
// and a created_at timestamp. Clicking a row opens a form built from
// form_schema and posts via respondHITL.
A HITLRequest carries a prompt (the question for the reviewer), a form_schema (a JSON Schema for the response fields), a status, and timestamps. Build the response object to match form_schema and pass it to respondHITL.
HITL requests need a dashboard row. Only workflows that include a human-in-the-loop node return a request here. Today, runs started with runWorkflow create a run but no dashboard row, and HITL nodes attach to a row — so HITL-heavy workflows are typically driven from the dashboard in the AI Employee builder, and your app surfaces the review step. Run-only HITL is a future enhancement on the workflow service.
The app database SDK
Each app gets its own MongoDB database. The template models it with Mongoose, but the same model code runs against two different backends depending on one environment variable — you never pick the mode in code:
When FRONTIER_GATEWAY_URL is… | Mode | What model() returns |
|---|---|---|
| set (builder sandbox and published apps) | SDK mode | A thin HTTP proxy from @ema-unlimited/frontier-apps-sdk/mongodb. Every operation routes to the gateway's database listener at /v1/db/{collection}/{action}. |
| absent (local dev via the create-app scaffold) | Mongoose mode | A real Mongoose model against a local MONGODB_URI. |
The picker lives in server/lib/db/index.ts (a protected file) and resolves at module load. Your job is to import model from that picker — never from mongoose directly — and to call connectDB() before queries (it's idempotent and a no-op in SDK mode). A model file looks like the template's note.ts:
import { Schema, type InferSchemaType } from 'mongoose'
import { model } from '../index.js' // import from the picker, NOT from 'mongoose'
const noteSchema = new Schema(
{
userId: { type: String, required: true, index: true },
tenantId: { type: String, required: true, index: true },
title: { type: String, required: true },
content: { type: String, default: '' },
},
{ timestamps: true },
)
export type NoteDocument = InferSchemaType<typeof noteSchema>
export const Note = model<NoteDocument>('Note', noteSchema)
Never put business logic in a Mongoose schema hook. In SDK mode the model is an HTTP proxy that knows nothing about the schema object you passed, so pre/post hooks, instance methods, and virtuals silently never run — with no error, no warning, and no log. A pre('save') that hashes a password would ship a plaintext field to production. Put that logic in the route handler instead, where it runs identically in both modes. Field types, required flags, and { timestamps: true } are safe because they're client-side or handled by the gateway.
A few more rules hold in both modes:
- Always filter by
tenantId(anduserIdwhere appropriate). The gateway does not enforce per-tenant scoping at the database layer — the app does. Skip the filter and a published app for one tenant could read another's documents. - No unbounded scans. An empty-filter
find({})with nolimitoption is rejected server-side (unbounded find() requires a limit option). Either narrow the filter or add a limit. - Some operations are blocked entirely in SDK mode —
drop(),dropCollection(),createIndex(),dropIndex(), andpopulate(). Use anaggregatewith$lookupinstead ofpopulate.
The database listener that backs SDK mode runs on a separate, network-isolated port on the gateway and is reachable only from app pods inside the frontier network. It trusts the app's identity from an environment value the publisher injects at pod creation — app code never controls it. You can also inspect and run queries against this database from the builder UI; see The app database.
The publish-time URL guard
Because the one-outbound-path rule is the platform's core safety property, it is enforced in two places. The unbypassable backstop is a kernel-level network policy on the published pod: its only allowed outbound destinations are DNS, the gateway, and the app's own database. A direct call to api.openai.com, api.anthropic.com, or any arbitrary host simply fails to connect.
The first line of defense is a fast feedback check at publish time. The template's guard script (pnpm run guard) walks src/ and server/, strips comments, and scans for any absolute http(s):// URL literal whose host falls outside the allowlist. If it finds one, it names the file, line, host, and URL, and fails the build:
guard: FAIL — forbidden URL literals detected.
server/routes/leak.ts:12 api.openai.com (in https://api.openai.com/v1/chat)
1 violation(s).
The default allowlist is the set of Ema-operated gateway and apex hosts plus localhost and 127.0.0.1. The guard skips template-literal and wildcard URLs (such as https://${host}/... or a https://*.ema.co content-security-policy pattern) because they aren't fetchable string literals, and it skips test files, which never run in the published pod. If you're targeting an Ema environment outside the default set, you can extend the allowlist for a run with the GUARD_ALLOWED_HOSTS environment variable; adding a genuinely new outbound host is a platform change (a network-policy edit), not an app-side one.
If the guard fails, the fix is almost always to remove the direct call and route it through EmuClient instead. The guard catches the obvious cases early so you don't discover the block at runtime; the network policy is the real enforcement.
What's next
- The App Editor — the chat, code editor, file tree, live preview, and where you bind AI Employee slots.
- Configuration and bindings — bind each declared slot to a workflow before publishing.
- App Permissions — the in-app role model your route handlers gate on.
- Deploying and Versioning — publish, the deployment lifecycle, and version history.