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
- A trigger fires (webhook, cron, poll) or you start a manual test run.
- The platform installs
package.json, bundlesmain.ts, and boots a fresh Bun sandbox. - It calls your module's default export with
(input, context). - Returning normally marks the run completed. Throwing marks it failed and surfaces the error to the operator.
- Files you wrote with
context.writeArtifactand 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| 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; 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/corecarries no runtime value for it. - Narrow the trigger payload, don't cast it.
inputisunknown. Pull one field at a time behind atypeof x === 'string'or'key' in objguard. Never||-chainascasts to coalesce; that widens the union and fails the type check. - File inputs are workspace paths. An
inputSchemaproperty declared with"format": "file"arrives as a relative path string (inputs/<field>/<filename>) pointing at a file already present in the workspace. Read it withreadFile(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 },
],
}),
})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).
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:
fetch(url)first. Simple and free.- Blocked (403, captcha)? Use
proxy-scrape, which returns markdown by default. - Content missing (single-page app)? Add
shouldRenderJs: true. - Still blocked? Escalate
proxyMode:defaultthenpremiumthenstealth.
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.
| Format | Library | Notes |
|---|---|---|
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
Server-side Bun. No browser, no DOM, no window, no document.
- No
process.envfor credentials or platform auth. Bun runs without bash-style env injection, soprocess.env.PLATFORM_API_KEYand friends areundefined. Usecontext. - Always check
response.okafter 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
rawMIME, RFC 2047-encode a non-ASCII Subject, use\r\nline endings, and encode withBuffer.from(raw, 'utf8'), neverbtoa.
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.
Resource
Author resources (workflows, apps, and skills) locally and run them in the cloud. Scaffold files to disk, edit them, then validate, wire credentials, and publish.
Agent workflows
The full contract for an agent workflow. An LLM agent runs instructions.md as its task, invoking reusable scripts via bash, with its runtime budget shaped by agent.json.