Block types · job

Build a job block

A one-shot, exits-when-done block. The bread-and-butter type — most blocks on the platform are jobs. This guide walks you all the way from `mkdir` to a verified successful run, with the same code I used to test the platform end-to-end.

What a job is

A job is a container that the platform spins up on demand, runs once, and tears down. It receives its inputs as environment variables, optionally calls an LLM via the proxy, writes its result to /out/outputs.json, and exits.

Lifecycle, in one diagram:

user clicks Run / hits CLI
       │
       ▼
 platform pulls image  ──►  container starts
                              │
                              ▼
                   reads INPUT_* env vars
                              │
                              ▼
                  calls inference proxy (optional)
                              │
                              ▼
                  writes /out/outputs.json
                              │
                              ▼
                       exits (code 0)
                              │
                              ▼
   platform marks Run "succeeded", surfaces outputs

The container only exists for the duration of the run. Anything you stash on disk outside /out is gone the moment the container exits.

When to pick job

  • The work is bounded — it has a clear "done".
  • It takes inputs, produces outputs, and the caller wants those outputs back.
  • You don't need state across runs (or you persist state yourself, e.g. to S3 or a database).
  • Examples: summarisers, translators, extractors, code reviewers, data-cleaning agents, research bots, retrieval pipelines, batch enrichers.

When NOT to pick job

  • You need a schedule — pick worker. (Workers have the same code shape as jobs, plus a cron field.)
  • You need a long-running HTTP endpoint other code can call — pick service.
  • You want a per-user chat / app / IDE — pick session.
  • You're composing several existing blocks into a pipeline — pick workflow.

What you'll build

A tiny e2e-job block that takes a single English word, asks Qwen3-235B to define it in one sentence, and writes the definition to outputs.json. Total source: three files, ~40 lines of code. The same shape scales up to much bigger jobs — the only thing that changes is what runs between "read inputs" and "write outputs".

At the end you'll have run it from the CLI like this and gotten a real definition back:

$ gonkablocks run admin/e2e-job word=serendipity
starting run…
✓ run started: https://blocks.gonka.gg/runs/cmoogmnin00054a1dcl3mh3y4
 • running
 • succeeded
outputs: {"definition":"Serendipity is the unexpected discovery of something good by chance."}

Project layout

mkdir e2e-job && cd e2e-job
touch manifest.yaml Dockerfile main.py

Three files. The CLI tars up the entire directory on deploy, skipping common noise (.git, .venv, node_modules). Keep your block dirs slim — under 50 MB on disk is a good rule of thumb.

manifest.yaml

The manifest declares what the block is. Inputs, outputs, runtime, resource limits, network policy, pricing. It's validated server-side at deploy time, so a typo here fails fast — you'll never deploy a half-broken manifest by accident.

name: e2e-job
version: 0.1.0
type: job
description: One-shot job that defines a word with the model.
category: misc

inputs:
  word:
    type: string
    required: true
    description: A single English word to define.

outputs:
  definition:
    type: string
    description: A short, human-readable definition of the input word.

runtime:
  build: dockerfile
  entrypoint: python main.py
  outputs_dir: /out
  env:
    MODEL: qwen3-235b

resources:
  cpu: 1
  memory_mb: 512
  timeout_seconds: 120
  network: deny

pricing:
  type: per_run
  base_price_cents: 0
  rate_cents_per_minute: 0
  inference_pass_through: true
  inference_markup_pct: 0

Field-by-field

  • type: job — required for this guide; tells the orchestrator to wait for a clean exit and read /out/outputs.json.
  • inputs — every key here becomes an env var named INPUT_<UPPERCASE> inside the container. Marked required: true means the run UI / CLI rejects the request if the caller didn't provide it.
  • outputs — declarative description; the platform doesn't enforce that you write every key, but it surfaces them on the block's public page so callers know what to expect.
  • runtime.build: dockerfile — there are other options (none for workflows), but for jobs it's always dockerfile.
  • runtime.entrypoint — the command run as PID 1 inside your container. This overrides any CMD in the Dockerfile. If you put a placeholder here that doesn't actually start your code, your container will exit cleanly and the run will fail with the cryptic container exited (code=0) message. (See pitfalls.)
  • runtime.outputs_dir — directory the platform mounts and reads after the container exits. Default is /out; rarely a reason to change it.
  • runtime.env — non-secret config baked into every run. Secrets go through the secrets vault — see the secrets section in the foundation guide.
  • resources.timeout_seconds — hard kill if the run blows past this. Bound your jobs! 120s is plenty for a single LLM call; agents that loop should set 600+ but always have an internal cap too.
  • resources.network: deny — the container can still reach the inference proxy (it's injected over a unix socket), but cannot make arbitrary outbound HTTP calls. deny is the right default for jobs that just call the model.
  • pricing.inference_pass_through: true — the caller pays Gonka's raw inference rate; you don't take a cut. Flip to false + inference_markup_pct to turn the block into a paid product.

Dockerfile

FROM python:3.12-slim
WORKDIR /workspace

# pin the SDK and httpx — newer httpx breaks the openai 1.55 path
RUN pip install --no-cache-dir 'openai==1.55.0' 'httpx<0.28'

COPY . /workspace
CMD ["python", "main.py"]

Notes on this exact image:

  • python:3.12-slim is ~50 MB. Stick with slim unless you specifically need something glibc-only.
  • The httpx<0.28 pin matters: newer httpx removed an internal API that openai==1.55 still uses, and you'll get a confusing import-time error otherwise.
  • The CMD here is ignored at run time — the platform uses runtime.entrypoint from the manifest. Keeping CMD in the Dockerfile is still useful so you can docker run --rm img locally for a smoke test.

main.py

"""Minimal one-shot job: define a word with the model, write outputs.json."""

import json
import os
import sys

from openai import OpenAI

client = OpenAI(
    base_url=os.environ["OPENAI_BASE_URL"],
    api_key=os.environ["OPENAI_API_KEY"],
)

MODEL = os.environ.get("MODEL", "qwen3-235b")
OUT_DIR = os.environ.get("GONKA_OUTPUTS_DIR", "/out")

word = os.environ.get("INPUT_WORD", "").strip()
if not word:
    print("ERROR: INPUT_WORD is required", file=sys.stderr)
    sys.exit(1)

print(f"==> Defining: {word}")

resp = client.chat.completions.create(
    model=MODEL,
    messages=[
        {
            "role": "system",
            "content": (
                "You are a concise dictionary. Reply with one short,"
                " plain-English sentence."
            ),
        },
        {"role": "user", "content": f"Define: {word}"},
    ],
    temperature=0.1,
)
definition = (resp.choices[0].message.content or "").strip()
print(f"==> {definition}")

os.makedirs(OUT_DIR, exist_ok=True)
with open(os.path.join(OUT_DIR, "outputs.json"), "w") as f:
    json.dump({"definition": definition}, f, indent=2)

print("==> done")

That's the whole block. Three things worth highlighting:

  • You never write the API key yourself. The platform injects a per-run OPENAI_API_KEY=sk-run-… and OPENAI_BASE_URL at start-up. They're scoped to this run only and revoked when the container exits.
  • Standard OpenAI SDK works as-is. The proxy is OpenAI- and Anthropic-compatible, so you can copy any existing tool/agent code directly. Same for @openai/openai on Node, openai-go, etc.
  • stdout / stderr are streamed live. Anything you print shows up in the run viewer in real time. Use that for progress logs (==> step 2/4 …) — much nicer than a silent 30 s wait.

How inputs reach your code

Each declared input foo becomes the env var INPUT_FOO. For typed inputs:

  • string / integer / number / boolean — passed as a string. Coerce with int(), float(), or check == "true" for booleans.
  • secret — also an env var, but resolved from the secrets vault and never logged.
  • file — the platform downloads the upload to a temp path and sets INPUT_<NAME> to the absolute path. Read from there as a normal file.
  • json / array — JSON-encoded; parse with json.loads.

Defaults from the manifest are applied before the env var is set, so os.environ["INPUT_FOO"] always works for any input that has a default or is required.

Writing outputs

After the container exits successfully (code 0), the platform looks for /out/outputs.json (or whatever you set outputs_dir to). The JSON is parsed and stored on the run row; callers see it as the run's structured output.

Any other files in /out (markdown, images, CSVs) are uploaded to object storage and attached to the run as downloadable artifacts. Reference them from outputs.json by relative path:

# inside main.py
import json, os
out_dir = os.environ.get("GONKA_OUTPUTS_DIR", "/out")
os.makedirs(out_dir, exist_ok=True)

with open(os.path.join(out_dir, "report.md"), "w") as f:
    f.write("# Report\n\n...")

with open(os.path.join(out_dir, "outputs.json"), "w") as f:
    json.dump({"report_path": "report.md", "definition": "..."}, f)

The block's public page renders .md files inline, .png/.jpg/.svg as images, and everything else as a download button.

Deploy

From inside the project dir:

$ gonkablocks deploy
packaging /private/tmp/gbk-e2e/e2e-job → e2e-job@0.1.0…
uploading 2 files…
building…
 • building
 • ready
publishing…
✓ published. View at https://blocks.gonka.gg/blocks/admin/e2e-job

What just happened, in order:

  1. The CLI tarred up your dir (skipping junk), signed it with your API key, and uploaded it.
  2. The platform validated manifest.yaml against the schema. A bad field here would have failed here, not at run time.
  3. A build daemon ran docker build on your Dockerfile. Build logs are streamed back; if the build fails you see the exact line.
  4. The image was tagged gonkablocks/<you>/e2e-job:0.1.0 and stored. The block was marked currentVersionId 0.1.0.

Run it

Three ways. Pick whichever fits the moment:

1. CLI

$ gonkablocks run admin/e2e-job word=serendipity
starting run…
✓ run started: https://blocks.gonka.gg/runs/cmoogmnin00054a1dcl3mh3y4
 • running
 • succeeded
outputs: {"definition":"Serendipity is the unexpected discovery of something good by chance."}

Inputs after the slug are key=value. Quoted values and JSON values both work: gonkablocks run admin/e2e-job word="quasi serendipity", gonkablocks run admin/foo --input bar='[1,2,3]'.

2. Public block page

Open https://blocks.gonka.gg/blocks/<you>/e2e-job — the platform renders a form from the manifest's inputs: block. Anonymous callers get 10 free runs/week; logged-in callers pay only for inference (which is a fraction of a cent for this job).

3. REST API

curl -X POST https://blocks.gonka.gg/api/runs \
  -H "Authorization: Bearer $GONKA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"author":"admin","slug":"e2e-job","inputs":{"word":"serendipity"}}'

Returns { id, status, url }. Poll GET /api/runs/<id> until status is succeeded or failed, then read outputs. (Full breakdown in the external-access guide.)

Iterate (versions)

Every deploy requires a new version: in the manifest. The platform refuses to overwrite an existing version — that way already-published runs always know exactly which image they ran against.

# manifest.yaml
- version: 0.1.0
+ version: 0.1.1
$ gonkablocks deploy
…
publishing…
✓ published. View at https://blocks.gonka.gg/blocks/admin/e2e-job

After deploy, currentVersionId is bumped to the new version. Old versions stay built and runnable — the version dropdown on the block page lets a caller pin to a specific one if they want.

Versioning rules of thumb:

  • Bump patch (0.1.0 → 0.1.1) for prompt tweaks, model swaps, bug fixes.
  • Bump minor (0.1.0 → 0.2.0) when you add an input or output without breaking existing callers.
  • Bump major (0.x → 1.0) when you rename or remove inputs/outputs. Callers will need to read the changelog.

Pitfalls I actually hit

  • container exited (code=0) with no useful logs. This means your runtime.entrypoint didn't actually start your code. Most common cause: entrypoint: python main.py where main.py is a library that just defines functions/classes and never if __name__ == "__main__": main(). Run the script directly with docker run --rm img locally to confirm it does something.
  • Missing input env var. If you forget to mark an input required: true and don't set a default, the env var simply doesn't exist. Always default-or- require — never assume.
  • Outputs not surfacing. The file must be exactly outputs.json in outputs_dir, must be valid JSON, and must be written before the container exits. Use a finally-block if your code can crash partway through.
  • httpx.ConnectError: Connection refused when calling the model. You're using http://localhost:3000 instead of the injected $OPENAI_BASE_URL. Always read it from the env — it differs between local gonkablocks exec and remote runs.
  • Timeouts during agentic loops. If you frequently hit timeout_seconds, add an internal cap on iterations before raising it. A stuck loop will burn your spend cap whether or not the wall clock has run out.
  • Big images = slow cold starts. If your Dockerfile pulls torch or transformers, you're looking at 2-5 GB images and 30 s+ cold starts. For pure-text agents, you almost never need them.

Cleanup

Jobs are stateless: there's nothing to stop, no container to reap. To remove a block entirely (including all its run history), open the block's settings page and click Delete block. Or:

curl -X DELETE https://blocks.gonka.gg/api/blocks/<blockId> \
  -H "Authorization: Bearer $GONKA_API_KEY"

To unpublish without deleting (so existing runs still resolve but no new public ones can be started), set isPublic: false via the block settings or the API. Old runs keep their structured outputs forever.

Next steps