> Source: https://builder.ema.ai/v2/frontier-apps-builder/calling-ai-employees-from-app-code
> Title: Calling AI Employees from App Code

# 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](/builder/v2/frontier-apps-builder) and [The App Editor](/builder/v2/frontier-apps-builder/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.

> [INFO]
> **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.

```ts
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](/builder/v2/frontier-apps-builder/the-app-editor#configuration-and-bindings) for the binding UI and the [overview](/builder/v2/frontier-apps-builder#a-note-on-ai-employee-slots) 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:

```ts
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
}
```

> [WARNING]
> **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:

```ts
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.

```ts
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:

```ts
// 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:

```ts
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`.

> [INFO]
> **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`:

```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)
```

> [DANGER]
> **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` (and `userId` where 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 no `limit` option 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()`, and `populate()`. Use an `aggregate` with `$lookup` instead of `populate`.

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](/builder/v2/frontier-apps-builder/the-app-editor#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:

```text
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.

> [TIP]
> 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](/builder/v2/frontier-apps-builder/the-app-editor) — the chat, code editor, file tree, live preview, and where you bind AI Employee slots.
-   [Configuration and bindings](/builder/v2/frontier-apps-builder/the-app-editor#configuration-and-bindings) — bind each declared slot to a workflow before publishing.
-   [App Permissions](/builder/v2/frontier-apps-builder/app-permissions) — the in-app role model your route handlers gate on.
-   [Deploying and Versioning](/builder/v2/frontier-apps-builder/deploying-and-versioning) — publish, the deployment lifecycle, and version history.
