Docs
Resource

Code workflows

The full contract and runtime API for a code workflow. A single main.ts that runs server-side in Bun once per trigger, with typed credentials, platform auth, logging, and artifact output.

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 instead.

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

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
PathRequiredPurpose
workflow.jsonyesThe workflow document: trigger, inputs, declared credentials, consts.
main.tsyesA single default-exported async function (input, context).
package.jsonyesnpm dependencies, auto-installed before each run.
tsconfig.jsonnoOverride the strict default compiler options.

agent.json, instructions.md, and scripts/* are forbidden. Those belong to an agent workflow; their presence fails validation.

package.json

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

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

tsconfig.json

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

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 runs the real type check server-side and is the compile gate.

The entry point

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

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 second parameter. The canonical type lives at packages/core/src/types/context.ts; if anything here disagrees with that file, trust the file.

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

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

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.

getPlatformCredential is legacy

Use the top-level context.platformApiKey / context.platformBaseUrl for everything: the database proxy, scraping, and chat-completion. They are always present. 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.

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.

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. By the time run executes, the trigger has already delivered its payload, so there is nothing to self-register.

getCredential(inputKey)

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.

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)

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)

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(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.

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)

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.

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

Calling the platform

Databases

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

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.

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.

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.

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 },
    ],
  }),
})
intelligenceLevelUse when
lowMechanical work: pattern matching, labeling, extracting structured data.
mediumUnderstanding and rephrasing: read, compress, or reshape information.
highReasoning, 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).

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".

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

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.

FormatLibraryNotes
PDFpdf-libPure JS, coordinate drawing. Pair with pdf-lib-table for tables.
ExcelexceljsBuild a workbook, format cells, write to buffer.
PowerPointpptxgenjsSlides, text, tables, charts, images.
WorddocxDocument, Paragraph, Table; pack with Packer.toBuffer().
CSV@fast-csv/formatStream rows and pipe to a writable.
Charts (SVG)echartsinit(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

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

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.

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

geni 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, which promotes your files to live. Verify the published workflow with geni workflow test. To change an existing workflow's type, use geni workflow set-type, then rewrite the files for the new type.

On this page