Block types · worker

Build a worker block

A worker is a job that the platform fires on a schedule. Same source code as a job — the only thing that changes is the type in the manifest, plus a cron expression you register once. This guide walks the full loop end-to-end with code I verified live.

What a worker is

A worker is structurally a job: container starts, does its thing, writes outputs.json, exits. The difference is who triggers it. For jobs, a human (or some external caller) hits Run; for workers, an in-process cron scheduler on the platform fires it on the cadence you configured.

every minute the scheduler ticks:
  ┌──────────────────────────────────────────┐
  │ for w in workers where enabled = true    │
  │     and nextRunAt <= now:                │
  │   start a run with w.inputs              │
  │   set lastRunAt = now                    │
  │   set nextRunAt = next(w.schedule)       │
  └──────────────────────────────────────────┘

The scheduler lives in src/server/scheduler.ts and uses croner for cron parsing — standard 5-field syntax, plus seconds if you really want them.

When to pick worker

  • You want the block to run without anyone clicking anything.
  • The cadence is regular: every X minutes/hours/days.
  • Common shapes: daily standup digest, hourly news pull, nightly DB sync, periodic LLM-backed monitoring ("does this dashboard look weird?").
  • Each tick is independent and short-ish. (For very long workflows, schedule a thin worker that kicks off a longer job.)

When NOT to pick worker

  • You only need to run on user click — that's a job.
  • You need a long-running process that's always up — use a service instead. Workers are short-lived per tick; if you need warm state, you'd be paying cold-start over and over.
  • The thing you want to schedule needs irregular triggers (webhooks, queue messages). Use a service with a POST endpoint and have your event source call it.

What you'll build

A worker called e2e-worker that, every two minutes, asks the model for a 3-line haiku about a topic stored in its inputs. Tiny on purpose so the full loop (deploy → register schedule → watch the scheduler fire it → disable) is easy to verify.

By the end you'll have seen this in your run list:

succeeded   e2e-worker    cmoogq152001h4a1de10sddvd
succeeded   e2e-worker    cmoogo4ib000s4a1dcck1uwox

…with the second run fired by the scheduler, not by you.

manifest.yaml

name: e2e-worker
version: 0.1.0
type: worker
description: End-to-end test worker. Writes a one-line haiku each tick.
category: misc

inputs:
  topic:
    type: string
    required: false
    default: "Hello from a scheduled worker"
    description: Topic the worker writes a haiku about.

outputs:
  haiku:
    type: string
    description: A 3-line haiku about the topic.

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

Differences from the job manifest in the previous guide:

  • type: worker — that's it. The platform uses the same orchestrator for the actual run; the type just enables the "Schedule" UI on the block page, and tells the scheduler to look at this block.
  • All inputs should have default or be optional. The schedule is registered once with a fixed input bag — there's nobody to type new values at 03:00. Required-without-default + scheduled = the run will fail.

main.py

Identical shape to the job. The container has no idea it's being scheduled — env vars in, JSON out, exit.

"""Worker entrypoint — same shape as a job, but the manifest type is
'worker' so the platform can invoke it on a schedule.
"""

import json
import os
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")

topic = os.environ.get("INPUT_TOPIC", "Hello from a scheduled worker").strip()
print(f"==> Worker tick. Topic: {topic}")

resp = client.chat.completions.create(
    model=MODEL,
    messages=[
        {
            "role": "system",
            "content": (
                "Reply with a single 3-line haiku. Plain text. No"
                " preamble, no quotes, no markdown."
            ),
        },
        {"role": "user", "content": topic},
    ],
    temperature=0.7,
)
haiku = (resp.choices[0].message.content or "").strip()
print(haiku)

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

Same Dockerfile as the job guide too — copy that one verbatim.

Deploy the block

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

Note that the block builds and is publishable on its own — at this point no schedule exists and the block won't fire automatically. You can also run it manually right now with gonkablocks run admin/e2e-worker to verify the code works before you let cron loose on it.

Register a schedule

Two ways to do this. Either is fine.

Option A: the block's page

Open the block in your browser, click Schedule, fill in the cron expression, the inputs, and a per-run spend cap, click Enable. The platform validates the cron immediately — bad expressions fail at form submit, not at midnight.

Option B: REST API

# 1. resolve the block id from author/slug
curl -H "Authorization: Bearer $GONKA_API_KEY" \
  "https://blocks.gonka.gg/api/blocks/resolve?author=admin&slug=e2e-worker"
# {"blockId":"cmoogniua000o4a1drn0dtc43"}

# 2. create a Worker row
curl -X POST https://blocks.gonka.gg/api/workers \
  -H "Authorization: Bearer $GONKA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "blockId": "cmoogniua000o4a1drn0dtc43",
    "name": "e2e haiku tick",
    "schedule": "*/2 * * * *",
    "inputs": {"topic": "morning espresso"},
    "spendCapCents": 20
  }'
# {"worker":{"id":"cmoogpgol001f4a1df6e1xe8w", ..., "nextRunAt":"2026-05-02T14:54:00.000Z"}}

The response includes nextRunAt — the exact UTC moment the scheduler will fire it. Look at that field after your first POST to confirm cron parsed the way you expected.

Auth note: the workers endpoint accepts either a session cookie or a gk-live-… bearer token, scoped to the user who owns the block. You can't schedule someone else's public block as a worker — fork it first.

Cron syntax cheatsheet

Standard 5-field UNIX cron, in UTC. From left to right: minute, hour, day-of-month, month, day-of-week.

Want it to run…Expression
every 2 minutes*/2 * * * *
every 15 minutes*/15 * * * *
every hour, on the hour0 * * * *
every day at 09:00 UTC0 9 * * *
weekdays at 09:00 UTC0 9 * * 1-5
every Monday at midnight UTC0 0 * * 1
first of the month, 04:00 UTC0 4 1 * *

All times are UTC. The scheduler doesn't respect the viewer's timezone — convert before you schedule. (crontab.guru is great for sanity-checking expressions.)

Watch it fire

The scheduler ticks every 60s. Wait one or two ticks past your nextRunAt and check:

curl -H "Authorization: Bearer $GONKA_API_KEY" \
  https://blocks.gonka.gg/api/workers
# {"workers":[{
#   "name":"e2e haiku tick",
#   "schedule":"*/2 * * * *",
#   "lastRunAt":"2026-05-02T14:54:22.775Z",
#   "nextRunAt":"2026-05-02T14:56:00.000Z",
#   ...
# }]}

Once lastRunAt is populated, a fresh run will appear in your run list:

$ gonkablocks runs
succeeded   e2e-worker    cmoogq152001h4a1de10sddvd   <-- fired by scheduler
succeeded   e2e-worker    cmoogo4ib000s4a1dcck1uwox   <-- earlier manual run

The scheduled run is just a normal Run row — it has live logs, structured outputs, and a viewer URL like any other. You can also browse them filtered by worker on the block's schedule page.

Manual runs alongside

A worker block is still a regular block — you can hit gonkablocks run on it any time, with different inputs, even while the schedule is active. Manual runs don't affect nextRunAt; they're a separate flow.

That's very useful for: testing a new prompt before it becomes the next scheduled tick, replaying a failure with diagnostic input, or letting a human override the cadence once.

Spend caps

spendCapCents is the most important field on a worker. Default is 50 ¢; tune it per the workload.

  • The cap is per run, not per day. A misbehaving worker that ticks every minute can still rack up bills if you set a high cap.
  • When inference cost crosses the cap mid-run, the proxy starts returning errors and your code can either retry (waste!) or detect and exit cleanly. Prefer the latter:
from openai import APIStatusError
try:
    resp = client.chat.completions.create(...)
except APIStatusError as e:
    if e.status_code == 402:
        print("==> hit per-run spend cap, exiting cleanly")
        sys.exit(0)
    raise

For a worker that ticks every 2 minutes, 20 ¢/run × 720 runs/day = $144 worst case. Set caps with that math in mind.

Pause, edit, delete

From the schedule page or the API:

# pause (keeps the row, just sets enabled=false)
curl -X PATCH https://blocks.gonka.gg/api/workers/<workerId> \
  -H "Authorization: Bearer $GONKA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled": false}'

# update schedule or inputs
curl -X PATCH https://blocks.gonka.gg/api/workers/<workerId> \
  -H "Authorization: Bearer $GONKA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"schedule": "*/15 * * * *", "inputs": {"topic": "tea"}}'

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

Disabling preserves the run history. Deleting a worker does not delete its past runs — those stay tied to the block.

Pitfalls

  • Cron in local time. All schedules are UTC. A worker scheduled at 9 9 * * * is firing at 09:00 UTC, not your local 09:00. If your users care about local time, do the conversion in your head once when you set up the worker.
  • Required input with no default. The scheduler can't prompt for missing inputs. If your manifest declares required: true with no default, the scheduled run will fail validation. Either default everything, or store the value in the worker's inputs field at schedule-creation time.
  • Two ticks at the same time. If the scheduler is delayed (rare, but happens during deploys), two ticks may fire back-to-back to catch up. Make your worker idempotent — re-running shouldn't corrupt the downstream state.
  • Drift on slow runs. If a tick is supposed to fire every 2 min but takes 3 min, the next fire is computed from the cron expression, not from the previous finish. Slow workers will overlap unless you cap their runtime.
  • The scheduler runs on the platform, not your machine. Closing your laptop doesn't pause anything. To stop a worker, disable it via the API or UI.

Next steps

  • Want the worker to write to a database / S3 / Notion? Whitelist hosts in resources.network and use a type: secret input for the credentials. Full breakdown in the secrets section.
  • Want a per-user chat instead of a scheduled tick? Session blocks.
  • Want an HTTP endpoint your other systems can hit? Service blocks.