DEV Community

Avery Lin
Avery Lin

Posted on

Give the Agent a Curfew

A solo founder stares at a half-finished webhook on Friday night. Three angry customer mails already sit in the inbox. Sleep can wait, but a cloud invoice cannot.

The founder pastes the repo into a coding agent and leaves. Morning returns a hot laptop and a surprise cloud bill. The agent had retried one flaky tool for six hours.

The webhook still failed after all that wasted heat. This night is a systems problem, not a prompt problem. Weekend shipping dies when the host has no curfew.

The model never truly calls an HTTP API. It emits a structured object with a name and arguments. A local host parses that object and chooses whether to run it.

Product demos hide that handoff behind a friendly chat bubble. Indie work cannot afford that particular kind of hiding. The host is the lock on the door.

Think of the model as a junior contractor with a temporary keycard. The keycard must expire at midnight without any debate. The building should keep few doors and one lobby camera.

Weekend agents need a written allowlist the host can refuse. They also need a dry run that never touches the public network. They finally need a wall-clock curfew that kills the process.

The allowlist comes before any prompt

A clever system prompt will not save a tired Friday. The founder should start with a file the host understands. Missing names become denials, and that silence is the feature.

Label the next block as example config, not production truth.

{
  "agent": "weekend-webhook",
  "timezone": "local",
  "curfew": {
    "max_wall_seconds": 1200,
    "max_tool_calls": 40,
    "max_retries_per_tool": 2
  },
  "tools": [
    {
      "name": "read_file",
      "args": ["path"],
      "network": false,
      "max_calls": 20
    },
    {
      "name": "run_tests",
      "args": ["suite"],
      "network": false,
      "max_calls": 8
    },
    {
      "name": "http_get",
      "args": ["url"],
      "network": true,
      "url_prefix": "https://staging.example.test/",
      "max_calls": 6
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The production deploy tool is missing on purpose here. The refund tool is missing on purpose here. Staging GET remains the only network door in the file.

A missing tool is a denied tool on this host. The agent can argue in prose all night long. The JSON file does not argue back at all.

What the host actually receives

Most coding models speak a small JSON dialect for tools. The payload carries a name string and an arguments object. A call identifier sometimes rides along for the next turn.

The host must treat that payload as untrusted input every time. String names can hide paths, shells, or surprise verbs. Arguments can nest until a naive runner finally explodes.

A strict host decodes with a size cap and a known schema. It rejects extra fields rather than forwarding them onward. It never interpolates arguments into a shell string.

# parse_call.py — example parser, not executed here
import json

MAX_CALL_BYTES = 4096

class CurfewError(RuntimeError):
    pass

def parse_call(raw: str):
    if len(raw.encode("utf-8")) > MAX_CALL_BYTES:
        raise CurfewError("call too large")
    data = json.loads(raw)
    if set(data.keys()) - {"name", "args"}:
        raise CurfewError("unexpected fields")
    if not isinstance(data.get("name"), str):
        raise CurfewError("bad name")
    if not isinstance(data.get("args", {}), dict):
        raise CurfewError("bad args")
    return data
Enter fullscreen mode Exit fullscreen mode

That parser is not clever and should remain that way. Clever parsers become the incident well after midnight. Boring parsers keep Friday night small and finite.

Customs at a tiny airport beats a speech about trust. Every incoming bag gets opened on the table. Extra pockets fail the check without appeal.

Dry-run the host, never the wallet

The next artifact is a tiny Python host for local rehearsal. It counts every attempted call against the published envelopes. Network tools return a skip record while dry-run stays true.

Label this sample as an unexecuted example harness.

# dry_run_host.py — example harness, not a product
import json, time, sys
from pathlib import Path

ALLOW = json.loads(Path("tools.allow.json").read_text())
counts = {}
started = time.monotonic()
MAX_CALL_BYTES = 4096

class CurfewError(RuntimeError):
    pass

def parse_call(raw: str):
    if len(raw.encode("utf-8")) > MAX_CALL_BYTES:
        raise CurfewError("call too large")
    data = json.loads(raw)
    extra = set(data.keys()) - {"name", "args"}
    if extra:
        raise CurfewError("unexpected fields")
    if not isinstance(data.get("name"), str):
        raise CurfewError("bad name")
    if not isinstance(data.get("args", {}), dict):
        raise CurfewError("bad args")
    return data

def check_curfew():
    elapsed = time.monotonic() - started
    if elapsed > ALLOW["curfew"]["max_wall_seconds"]:
        raise CurfewError("wall clock expired")
    if sum(counts.values()) >= ALLOW["curfew"]["max_tool_calls"]:
        raise CurfewError("call envelope empty")

def find_tool(name):
    for tool in ALLOW["tools"]:
        if tool["name"] == name:
            return tool
    return None

def handle(call, dry_run=True):
    check_curfew()
    tool = find_tool(call["name"])
    if tool is None:
        raise CurfewError(f"denied tool: {call['name']}")
    used = counts.get(tool["name"], 0)
    if used >= tool["max_calls"]:
        raise CurfewError(f"tool envelope empty: {tool['name']}")
    counts[tool["name"]] = used + 1
    url_prefix = tool.get("url_prefix")
    if url_prefix:
        url = call.get("args", {}).get("url", "")
        if not str(url).startswith(url_prefix):
            raise CurfewError("url outside prefix")
    if dry_run and tool.get("network"):
        return {"ok": True, "dry_run": True, "skipped": call}
    return {"ok": True, "dry_run": dry_run, "echo": call}

if __name__ == "__main__":
    try:
        call = parse_call(sys.stdin.read())
        print(json.dumps(handle(call, dry_run=True)))
    except CurfewError as exc:
        print(json.dumps({"ok": False, "error": str(exc)}))
        sys.exit(2)
Enter fullscreen mode Exit fullscreen mode

The host fails closed on unknown names and empty envelopes. Network tools in dry-run still spend the numbered envelope. Rehearsal that does not spend budget will hide retry loops.

Prove the gate with a command before any chat window opens. Type the denial cases in a real shell. Do this before the first long prompt.

echo '{"name":"stripe_refund","args":{"id":"re_123"}}' | python dry_run_host.py
# expected stderr-free JSON, exit 2, error denied tool: stripe_refund

echo '{"name":"http_get","args":{"url":"https://evil.example/"}}' | python dry_run_host.py
# expected exit 2, error url outside prefix

echo '{"name":"http_get","args":{"url":"https://staging.example.test/health"}}' | python dry_run_host.py
# expected exit 0, skipped http_get, dry_run true
Enter fullscreen mode Exit fullscreen mode

The refund name never appears in the allowlist file. The evil URL fails the prefix check even during dry-run. Both exits should be two, not zero.

Tests the chat window cannot charm

Indie founders skip tests when the clock hits midnight. That is exactly when the agent needs a test. Ten lines can pin the boundary without a rented GPU.

# test_curfew.py — example, unexecuted in this article
import json, subprocess

def run(call):
    proc = subprocess.run(
        ["python", "dry_run_host.py"],
        input=json.dumps(call),
        text=True, capture_output=True,
    )
    return proc.returncode, json.loads(proc.stdout)

def test_unknown_tool_is_denied():
    code, body = run({"name": "ship_prod", "args": {}})
    assert code == 2
    assert "denied" in body["error"]

def test_staging_get_is_skipped_in_dry_run():
    code, body = run({
        "name": "http_get",
        "args": {"url": "https://staging.example.test/health"},
    })
    assert code == 0
    assert body["skipped"]["name"] == "http_get"

def test_foreign_host_fails_prefix():
    code, body = run({
        "name": "http_get",
        "args": {"url": "https://evil.example/"},
    })
    assert code == 2
    assert "prefix" in body["error"]
Enter fullscreen mode Exit fullscreen mode

Run it like any other unit file in the repo. Green means the host still refuses stranger names. Red means the Friday host already drifted.

python -m pytest test_curfew.py -q
Enter fullscreen mode Exit fullscreen mode

The prompt can change every hour without ceremony. The allowlist should not change without a failing test. The test is memory the model does not keep.

A wall clock the process must obey

Allowlists stop the wrong door from opening tonight. They do not stop a long and confused night. The agent still needs a process-level timer around the loop.

# curfew.sh — example supervisor, not executed here
set -euo pipefail
export PYTHONUNBUFFERED=1
/usr/bin/timeout --kill-after=10s 1200s python agent_loop.py \
  --allow tools.allow.json \
  --dry-run \
  --max-calls 40
echo "agent stopped with $?"
Enter fullscreen mode Exit fullscreen mode

Twelve hundred seconds equal twenty focused minutes of work. That window can edit a stubborn webhook and run tests. It cannot cook the laptop until Saturday dawn.

The kill-after flag handles a frozen child process cleanly. The numeric exit code becomes the whole morning report. Humans should read four lines, not a novel.

When the laptop must close, the same script belongs elsewhere. A disposable remote box can run the curfew while sleep happens. That box must not hold production secrets or customer exports.

MonkeyCode provides free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. A throwaway staging repo is enough to try the bounded run.

A sketch of the loop

The loop itself should stay as dull as the parser. It asks the model for a proposal, then asks the host. The host return value becomes the next observation.

# agent_loop.py — sketch, not executed here
def loop(model, host):
    observation = {"task": "fix staging webhook tests"}
    for _ in range(40):
        proposal = model.propose(observation)  # untrusted text
        result = host.handle(parse_call(proposal), dry_run=True)
        if not result.get("ok", True):
            return result
        observation = result
    return {"ok": False, "error": "envelope empty"}
Enter fullscreen mode Exit fullscreen mode

Forty turns is not some personality trait. It is the call envelope from the allowlist. When the envelope empties, the process is done.

Live flags stay outside the agent's reach

Dry-run does not prove TLS, auth, or pagination behavior. A skipped call never exercises the real client path. Flip a live flag only after one recorded staging request.

Keep that recording in git beside the allowlist file. Keep the live flag in a file the agent cannot write. The host should refuse tools that can move customer money.

This remains a weekend fence, not a bank control plane. Fancy multi-agent planners can wait for a calmer Monday. A curfew is enough to ship a webhook tonight.

Who should not copy this fence

This workflow is for a solo founder on staging traffic. It is not a HIPAA boundary or a money-movement control. It is not a substitute for a second human review.

Free models drift from one night to the next without notice. The same prompt may emit a brand new tool name tomorrow. The allowlist must fail closed when that happens.

Free servers also sleep, throttle, or vanish without a contract. Do not park customer data on a box that can disappear. Do not treat complimentary uptime as an operations promise.

If the product can move money, this host is too small. Put a human in that loop before any refund tool exists. Give the agent a service account that cannot see billing.

Saturday morning, four lines

Shipping tonight means a failing webhook test now passes. It does not mean the agent gained root on production. It does not mean support mail became an unsupervised loop.

A useful morning log holds four dull lines and then stops. It shows wall time, tool counts, denials, and the diff stat. Anything beyond that is noise around a simple fence.

wall_seconds=842
tool_counts={"read_file":11,"run_tests":4,"http_get":2}
denials=["ship_prod","stripe_refund"]
diff_stat=webhook.py +18 -6
Enter fullscreen mode Exit fullscreen mode

The denials are the actual win from the long night. The agent asked for production ship and a customer refund. The host said no, and the founder still has a weekend.

Indie work rewards people who accept limits in public. Zero bill is a feature for a one-person company. Finite tools and a hard curfew are features too.

The model may be clever inside that small fence. Outside the fence it is an intern with a credit card. Friday can run late, but the agent still stops.

Top comments (0)