# Code workflows (/cli/resource/code)



A **code workflow** is deterministic. The same fixed sequence runs on every trigger. You ship a single `main.ts` that default-exports an async function; the platform bundles it and runs it server-side in Bun, inside an isolated sandbox, passing a typed `WorkflowContext` that provides credentials, platform auth, logging, and file output.

Reach for a code workflow when the steps are known up front: fetch from an API, transform the result, write it somewhere, send a notification. When the work is intent-based and the steps aren't known in advance (research, triage, drafting), use an [agent workflow](/cli/resource/agent) instead.

## How it runs [#how-it-runs]

1. A trigger fires (webhook, cron, poll) or you start a manual test run.
2. The platform installs `package.json`, bundles `main.ts`, and boots a fresh Bun sandbox.
3. It calls your module's **default export** with `(input, context)`.
4. Returning normally marks the run **completed**. Throwing marks it **failed** and surfaces the error to the operator.
5. Files you wrote with `context.writeArtifact` and the steps/logs you emitted appear in the run view.

The sandbox is isolated and ephemeral. Nothing survives between runs, and there is no shared filesystem, no `process.env` credential injection, and no browser or DOM.

## Project layout [#project-layout]

```
workflow.json     trigger, inputs, declared credentials, consts
main.ts           your code: one default-exported async function
package.json      npm dependencies, installed before each run
tsconfig.json     optional; a strict default is provided
```

| Path            | Required | Purpose                                                               |
| --------------- | -------- | --------------------------------------------------------------------- |
| `workflow.json` | yes      | The workflow document: trigger, inputs, declared credentials, consts. |
| `main.ts`       | yes      | A single default-exported async function `(input, context)`.          |
| `package.json`  | yes      | npm dependencies, auto-installed before each run.                     |
| `tsconfig.json` | no       | Override the strict default compiler options.                         |

`agent.json`, `instructions.md`, and `scripts/*` are **forbidden**. Those belong to an [agent workflow](/cli/resource/agent); their presence fails validation.

### `package.json` [#packagejson]

Standard npm. Declare `@general-input/core` plus anything else you import. Packages without bundled types should also list the matching `@types/*` package.

```json
{
  "type": "module",
  "dependencies": {
    "@general-input/core": "^0.2.0",
    "exceljs": "^4.4.0"
  }
}
```

### `tsconfig.json` [#tsconfigjson]

Optional. The platform provides a strict default (`ES2022`, `moduleResolution: bundler`, `strict: true`, `skipLibCheck: true`). Write your own only to override it.

<Callout type="warn" title="Don't compile locally">
  `@general-input/core` is injected server-side, not published to npm, so a
  local `npm install`, `bun`, or `tsc` always fails on the missing module.
  That's a dead end, not a real error. [`geni resource
    validate`](/cli/resource/validate) runs the real type check server-side and is
  the compile gate.
</Callout>

## The entry point [#the-entry-point]

`main.ts` default-exports a single async function. The runtime bundles the module and invokes its default export with `(input, context)`.

```ts
import type { WorkflowContext } from '@general-input/core'

export default async function run(input: unknown, context: WorkflowContext) {
  context.step('Starting workflow')

  const gmail = context.getCredential('gmail')
  const recipientEmail = context.getConst('recipientEmail')

  // Narrow the untyped payload behind a guard rather than casting it.
  if (input && typeof input === 'object' && 'rowData' in input) {
    context.log('info', 'Processing trigger', { keys: Object.keys(input) })
  }

  // ... do the work ...

  context.step('Sending notification')
}
```

Rules:

* **Import the type only.** `import type { WorkflowContext }`. A value import crashes at runtime, because `@general-input/core` carries no runtime value for it.
* **Narrow the trigger payload, don't cast it.** `input` is `unknown`. Pull one field at a time behind a `typeof x === 'string'` or `'key' in obj` guard. Never `||`-chain `as` casts to coalesce; that widens the union and fails the type check.
* **File inputs are workspace paths.** An `inputSchema` property declared with `"format": "file"` arrives as a relative path string (`inputs/<field>/<filename>`) pointing at a file already present in the workspace. Read it with `readFile(input.<field>)`. It is never a URL or inline content. For multiple files in one field, declare `{ "type": "array", "items": { "format": "file" } }` — the value arrives as an array of paths (`inputs/<field>/<index>/<filename>`); iterate it.

## The `WorkflowContext` API [#the-workflowcontext-api]

The second parameter. The canonical type lives at `packages/core/src/types/context.ts`; if anything here disagrees with that file, trust the file.

```ts
interface WorkflowContext {
  executionId: string
  workflowId: string
  membershipId: string
  platformApiKey: string
  platformBaseUrl: string
  trigger?: { id: string; key: string; payload: unknown }

  getCredential(inputKey: string): Record<string, unknown>
  getPlatformCredential(inputKey: string): Record<string, unknown> // legacy alias for the top-level platform auth
  getConst(key: string): unknown

  step(message: string): void
  log(
    level: 'debug' | 'info' | 'warn' | 'error',
    message: string,
    data?: unknown
  ): void

  writeArtifact(options: {
    filename: string
    mimeType: string
    label: string
    data: Buffer
  }): Promise<void>
}
```

### Identity [#identity]

`executionId`, `workflowId`, and `membershipId` identify the current run, its workflow, and the operator the run belongs to. Useful for log correlation and for scoping calls back into the platform.

### Platform auth [#platform-auth]

`platformApiKey` and `platformBaseUrl` are **always present**. They authenticate first-party platform endpoints: the database proxy, the LLM service, the scraping service. Use them directly with a `Authorization: Bearer ${context.platformApiKey}` header.

<Callout type="info" title="getPlatformCredential is legacy">
  Use the top-level &#x2A;*`context.platformApiKey` / `context.platformBaseUrl`** for
  everything: the database proxy, scraping, and `chat-completion&#x60;. They are always
  present. &#x2A;*`getPlatformCredential(inputKey)`** is a legacy alias that resolves a
  *declared* `platformCredential` slot from `workflow.json` and returns the *same*
  `{ platformApiKey, platformBaseUrl }` values. It stays wired for existing workflows,
  but new workflows declare no platform-credential slot and never call it.
</Callout>

### `trigger` [#trigger]

Present when a trigger fired the run, absent for manual test runs. `trigger.id` is an opaque execution-time identifier, not the id you declared, so never compare it to a trigger block's id. `trigger.key` is the id you set on the corresponding trigger block in `workflow.json`; `trigger.payload` is the same value passed as `input`. Branch on `trigger.key` when a workflow has more than one trigger.

```ts
if (context.trigger?.key === 'newRow') {
  // a specific trigger fired
}
```

The inbound webhook URL is **not** exposed at runtime. It is assigned when the workflow is published and read from the publish result or [`geni resource config`](/cli/resource/config). By the time `run` executes, the trigger has already delivered its payload, so there is nothing to self-register.

### `getCredential(inputKey)` [#getcredentialinputkey]

Returns the operator's resolved credential for the `inputKey` you declared in `workflow.json`. Field names are **camelCase** per the integration's schema (`accessToken`, `apiKey`, `instanceUrl`), never snake\_case. Throws if no credential is wired for that key.

```ts
const slack = context.getCredential('slack')
// OAuth credentials expose accessToken (the platform refreshes it for you).
// API-key credentials expose the field named in the integration schema, e.g. apiKey.
```

### `getPlatformCredential(inputKey)` (legacy) [#getplatformcredentialinputkey-legacy]

Returns the same `{ platformApiKey, platformBaseUrl }` as the top-level fields, for a `platformCredential` slot declared in `workflow.json`. Redundant and superseded: new workflows declare no platform-credential slot and call `chat-completion` with the top-level fields directly (see the LLM section). Still wired so existing workflows keep working; throws if called with an undeclared key.

### `getConst(key)` [#getconstkey]

Returns a declared const's value. A const whose schema declares a `default` is **optional**: if the operator leaves it blank, `getConst` returns that default (often `""`), so handle the blank case. A const with no `default` is **required**, and validation blocks the run until it is set. Throws only for a key that was never declared as a const.

### `step` and `log` [#step-and-log]

`step(message)` emits a human-readable progress marker shown in the run view. Use it to mark each major phase. `log(level, message, data?)` writes a structured entry visible in execution logs but not in the run summary. Both stream to the operator in real time.

```ts
context.step('Posting digest to Slack')
context.log('debug', 'Slack response', { status: res.status })
```

Log verbosely: at entry (`{ inputKeys: Object.keys(input) }`), around every API call, and on data transformations. **Never log secrets**, tokens, API keys, or passwords.

### `writeArtifact(options)` [#writeartifactoptions]

Writes an output file, surfaced in the run view with `label` and downloadable as `filename`. `data` is a `Buffer` (`Buffer.from(str)` for text, raw bytes for binary). Artifacts are **immutable**: writing a filename that already exists in the run throws.

```ts
await context.writeArtifact({
  filename: 'TopStories.json',
  mimeType: 'application/json',
  label: 'Top stories',
  data: Buffer.from(JSON.stringify(stories, null, 2)),
})
```

## Calling the platform [#calling-the-platform]

### Databases [#databases]

User databases are reached through the platform database proxy, authenticated with the top-level platform fields. No `getPlatformCredential` call, no `process.env` lookup.

```ts
const res = await fetch(`${context.platformBaseUrl}/database/user-database`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${context.platformApiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    databaseId: 'udb_abc123',
    sql: 'SELECT * FROM contacts',
  }),
})
if (!res.ok) {
  const err = await res.json().catch(() => ({}))
  throw new Error(err.error ?? `DB query failed: ${res.status}`)
}
const { rows, rowCount } = await res.json()
```

Reads and writes use the same endpoint. For writes, `rows` is empty and `rowCount` reflects affected rows. The full SQL API and admin endpoints (create, list) are documented as operations under the `user-database` integration; browse them with [`geni integration`](/cli/integration).

<Callout type="warn" title="Bulk writes fail loud">
  A run that meant to insert N rows but committed 0 is a **failed** run, not a
  success. Don't swallow per-row insert errors as warnings and report
  completion. Count failures and `throw` when the writes fail, so the run
  surfaces as errored instead of "completed, added 0". Match your INSERT column
  list to the live schema you confirmed with `PRAGMA table_info`; a mismatched
  column name fails every insert.
</Callout>

### LLM [#llm]

Call `chat-completion` with the top-level platform auth, the same `context.platformApiKey` / `context.platformBaseUrl` used for the database proxy. There is no separate LLM endpoint or key, and no `platformCredential` slot to declare.

```ts
const res = await fetch(`${context.platformBaseUrl}/chat-completion`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${context.platformApiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    intelligenceLevel: 'medium',
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: userMessage },
    ],
  }),
})
```

| `intelligenceLevel` | Use when                                                                 |
| ------------------- | ------------------------------------------------------------------------ |
| `low`               | Mechanical work: pattern matching, labeling, extracting structured data. |
| `medium`            | Understanding and rephrasing: read, compress, or reshape information.    |
| `high`              | Reasoning, creativity, expert knowledge: synthesize, argue, plan, write. |

Default to `medium`, and make `intelligenceLevel` and the system prompt const inputs so the operator can tune them. Message content can be multimodal: `{ type: 'text', text }`, `{ type: 'image', image: url }`, or `{ type: 'file', data: url, mimeType }` (URL or base64).

<Callout type="warn" title="Never set max_tokens">
  The platform model spends tokens on reasoning before it writes output, so a
  `max_tokens` cap gets consumed by reasoning and `content` comes back empty
  (`finish_reason: 'stop'`, zero length). Bound the length in the prompt
  instead, for example "in two or three sentences".
</Callout>

### Web scraping [#web-scraping]

Escalate only as far as you need:

1. `fetch(url)` first. Simple and free.
2. Blocked (403, captcha)? Use `proxy-scrape`, which returns markdown by default.
3. Content missing (single-page app)? Add `shouldRenderJs: true`.
4. Still blocked? Escalate `proxyMode`: `default` then `premium` then `stealth`.

Never fabricate researched values. If the source did not yield a real name, email, or phone number, leave it null. An invented `name@company.com` is worse than empty.

## Producing files [#producing-files]

Code workflows run server-side in Bun, **not** the agent sandbox, so `python3`, `reportlab`, and LibreOffice are unavailable. Use server-side JS libraries and emit the result via `writeArtifact`.

| Format       | Library            | Notes                                                              |
| ------------ | ------------------ | ------------------------------------------------------------------ |
| PDF          | `pdf-lib`          | Pure JS, coordinate drawing. Pair with `pdf-lib-table` for tables. |
| Excel        | `exceljs`          | Build a workbook, format cells, write to buffer.                   |
| PowerPoint   | `pptxgenjs`        | Slides, text, tables, charts, images.                              |
| Word         | `docx`             | `Document`, `Paragraph`, `Table`; pack with `Packer.toBuffer()`.   |
| CSV          | `@fast-csv/format` | Stream rows and pipe to a writable.                                |
| Charts (SVG) | `echarts`          | `init(null, null, { renderer: 'svg', ssr: true })`, render to SVG. |

Forbidden: `puppeteer`, `playwright`, `selenium`, `@react-pdf/renderer`, `jspdf`, `pdfkit`, `pdfmake`. They need a browser or are lower-quality alternatives.

## Execution environment [#execution-environment]

Server-side Bun. No browser, no DOM, no `window`, no `document`.

* **No `process.env` for credentials or platform auth.** Bun runs without bash-style env injection, so `process.env.PLATFORM_API_KEY` and friends are `undefined`. Use `context`.
* **Always check `response.ok`** after every platform or API call, and surface the error body. The runtime captures failed-fetch details and attaches them to a thrown error, so a thrown message plus a checked response gives the operator a precise failure.
* **Match the message format to the target.** Slack uses mrkdwn, email uses HTML, SMS uses plain text. When sending Gmail `raw` MIME, RFC 2047-encode a non-ASCII Subject, use `\r\n` line endings, and encode with `Buffer.from(raw, 'utf8')`, never `btoa`.

## Worked example [#worked-example]

A workflow that reads a trigger payload, writes the items to a downloadable artifact, and posts a summary to Slack. It exercises input narrowing, a const, a credential, an artifact, steps, and a checked response.

```ts
import type { WorkflowContext } from '@general-input/core'

export default async function run(input: unknown, context: WorkflowContext) {
  if (!input || typeof input !== 'object' || !('items' in input)) {
    throw new Error('Trigger payload missing "items"')
  }
  const items = input.items
  if (!Array.isArray(items)) throw new Error('"items" is not an array')

  context.step('Writing report')
  await context.writeArtifact({
    filename: 'Report.json',
    mimeType: 'application/json',
    label: 'Daily report',
    data: Buffer.from(JSON.stringify(items, null, 2)),
  })

  context.step('Posting to Slack')
  const slack = context.getCredential('slack')
  const channel = context.getConst('slackChannelId')
  const posted = await fetch('https://slack.com/api/chat.postMessage', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${slack.accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      channel,
      text: `Report ready: ${items.length} items`,
    }),
  })
  if (!posted.ok)
    throw new Error(`Slack ${posted.status}: ${await posted.text()}`)

  context.log('info', 'Posted report', { channel, count: items.length })
}
```

## Validating and publishing [#validating-and-publishing]

[`geni resource validate`](/cli/resource/validate) is the compile gate. It runs the spec checks, credential and const wiring, and a server-side TypeScript check against the real `@general-input/core`. A green validate means it is ready to [`geni resource publish`](/cli/resource/publish), which promotes your files to live. Verify the published workflow with [`geni workflow test`](/cli/resource/workflow/test). To change an existing workflow's type, use [`geni workflow set-type`](/cli/resource/workflow/set-type), then rewrite the files for the new type.
