Independent teaching notes. Not affiliated with xAI or Grok.

Week 5 of 6

Agent deployment and CI/CD

Same lesson at three depths. Easy is the start. You put the helper behind a checked web request, run slow work as a job, and let tests run before you ship. You also leave a runbook their team can use without you in the room.

In the pictures, the model is the program that writes the next words. A tool is a named action your program runs.

Flow to remember

Flow: a task, not a tool name. Health is open and does not call the model. Tasks need the bearer. Over 4096 bytes is refused. A repeated job key is the same job. The image does not contain the token, and rollback is the previous image tag.

Mind map: terms to recall

Recall: there is no URL that takes a tool name. Unauthorized does not call the model. The reply hides the transcript. The lab claim does not prove two workers. After a key conflict, look the row up by key. Pull requests do not call a provider. The nightly run pins the model name. Database changes add the new column before they require it.

Easy is the start. Plain words, short steps, and each new word is explained the first time. Medium and Pro are the same lesson on this page.

Why it matters

A loop that only runs inside a test is still a proof of concept. The production questions are dull. Who can call it. How large a body you will read. What happens when the model takes thirty seconds. How a second process picks up the document job from week 3 without doing it twice. How a proposed change proves the refund gate from week 4 still refuses 500000 cents. None of those are prompt questions. The model is the program that writes the next words. An agent does not get a special exemption from ordinary service rules.

Ship the smallest thing that has a real edge. One web process, one worker process, one database, one container image built from a lockfile you can rebuild. A container image is a packed snapshot of the program and its files. You do not need a platform team. You do need a rollback that is "run the previous image tag," and a database change the previous image can still boot against.

A web API that does not expose the tool table

An API is a way for one program to ask another to do something. Do not publish a URL where the caller picks a tool name and arguments. That URL is a shell. The caller would pick refund_order. Your server decides which loop runs and which tools that loop has. The client sends a task, or presses a button that maps to a task type you defined.

The reference edge is dispatch in httpapi.py. It is a function on purpose. Tests call it without opening a network socket. The socket wrapper is Handler. It reads at most MAX_BODY + 1 bytes so a lying size header cannot make you buffer a huge upload before you reject it. The cap is 4096 bytes. Past that, the status is 413 body_too_large.

  • GET /health returns 200 {"ok": true} with no token. A load balancer needs this. It must not touch the model, and it must not require the application token. If a missing secret made health fail, you could not use the health check to debug the secret, and a missing secret would look like a total outage.
  • POST /v1/tasks requires Authorization: Bearer equal to the server's NORTHLINE_TOKEN. A bearer token is a shared secret sent with the request. A missing server token is 500 server_token_unset, not an open service. A wrong bearer is 401. The body must be JSON with a string field task, at most 1000 characters. The response is final, error, and steps. The transcript is not in the response. Transcripts hold tool payloads, including emails.

There is no route that takes a tool name. Adding one "for internal debugging" without the same gateway (code that can still refuse the action) is how the allowlist, the only tool names that loop may run, gets bypassed by your own script. If you need a debug route, put it on a different port that is not reachable from the outside, and still run the gateway.

This lab uses one shared bearer so the lesson fits on one page. A shop you actually run wants a session or a per-caller key, a shopper id on that session, and that shopper id passed into lookup_order. Week 4 said this. The web function is where it has to show up. Do not read the shopper id from the JSON body. The body is controlled by the user and the model. The session is yours.

The web handler should give up even if the loop does not. Week 1's step budget bounds model calls. It does not bound a tool that waits on the network. Set a time limit on tool calls, and a deadline on the request. For anything that can exceed a few seconds, return a job id instead. Holding a worker thread for a minute-long agent is how you run out of threads during a small incident.

A job table and a worker

Ingestion and long agent runs are jobs. The reference table in jobs.py is SQLite, which is enough to show states and idempotency. Idempotency means a retry is still one business action. Columns: id, idempotency_key unique, status, payload, attempts. enqueue inserts queued or returns the existing id when the key repeats. The repeat path selects by idempotency_key. It does not trust lastrowid, which stays on the previous insert when the conflict does nothing. claim takes one queued row and marks it running. finish sets done or failed.

SQLite in the lab does not demonstrate multiple workers. BEGIN IMMEDIATE on one connection is a lock for that connection. It is not the Postgres form SELECT … FOR UPDATE SKIP LOCKED. On Postgres, claim one queued row whose run_after has passed, with FOR UPDATE SKIP LOCKED and LIMIT 1, inside a transaction, then set status='running'. Two workers can claim different rows. They cannot claim the same row. The comment in jobs.py says this, and the test only covers a single claimer. Do not write "we use SKIP LOCKED" because the SQLite demo felt concurrent. If you run two threads on the lab's claim with one shared connection, you have not reproduced Postgres. Use real Postgres where you test workers, even if it is a container on your laptop.

The client retries the web call. You must not create two jobs that each refund order 1043. The key is client-supplied only if the client is your own screen that holds the key stable across retries. A key the model invents is a random string, not an idempotency key.

Attempts increment on claim. After a small cap, finish as failed and stop. A job that crashes after the side effect must, on retry, hit the refund key and observe already_committed. If the job is not idempotent, retries are a second side effect. Do not add retries to a non-idempotent write and call it reliability.

Payloads are JSON for a task or an ingest request: document id, version, access list, source address. The worker that ingests uses the week 3 function. It does not run inside the web process. Same image, different command, is a clean split. Web command: serve HTTP. Worker command: loop on claim.

Checks, the image, and rollback

The pipeline has two speeds, matching week 4.

  • Pull request. A pull request is a proposed change. Run the program checks, including python3 test_all.py. No provider call. No secret required. The job stays boring and fast, so it stays enabled.
  • Nightly. The live eval subset, with a pinned model name, against secrets stored in the CI secret store. Failure of a safety fact files an issue or pages, using the week 4 rule: not on a single timeout. CI is the automation that runs those checks.
  • Image build. After the pull-request suite is green on the commit you intend to ship. The image contains code, not NORTHLINE_TOKEN, not a provider key, not a database URL. Those arrive as environment variables at runtime. If the image history shows a token, you have already leaked it into every registry cache. Rotate the token. Do not "delete the layer" and assume registries forgot.

A Dockerfile that only runs the suite is still worth having, because it proves the image boots and the tests pass in the same files you will ship. The lab image is that thin, and it switches to user id 10001 before python3 test_all.py. It does not start the web server. To serve, keep that user and change the command to python3 -m northline.httpapi. Do not delete USER so the container goes back to root.

Change the database in two deploys, not one. First add the new column and allow it to be empty. Ship code that writes both the old column and the new one, and fill in the old rows. In a later deploy, make the new column required and stop reading the old one. The previous image has to start if you roll back. If you rename a column in the same release that stops using the old name, rollback boots and then crashes.

Health checks decide rollback. If /health fails after the new task starts, keep the old task. If /health always returns 200 even when the database is down, you will send traffic to a process that cannot claim jobs. Check the dependency you cannot serve without. Do not check the model provider inside /health. A provider incident would mark every copy down, and you would have no process left to return a decent error.

One machine with two containers is a legitimate production for this project. Do not wait for a cluster to practice the pipeline. Do practice: build the image, push it to a registry you control, run it with the token injected, hit /health, hit /v1/tasks with the bearer (the shared secret that route requires), and roll back by running the previous tag. If you have never done the rollback on a quiet afternoon, you do not have a rollback.

Handover and the runbook

Handover means the customer's team can run the pilot without you in the room. A runbook is the short page they open when it breaks. It is not a tour of the code. Write, in their words: how to tell it is down, how to stop it, how to roll back to the previous image, who to call, what a failed job looks like, and which button not to press twice.

The rollback notes above are the start: the previous image tag, the command, and the rule for database changes. Add the person who is allowed to run that command after you leave. If only you know the tag, you did not hand it over. Practice the page on a quiet afternoon with someone who did not write the service. If they have to ask you what "claim" means, define it on the page. To claim a job is to take one waiting job so a second worker does not take it too.

The account that connects to their order system is one they create for the pilot. It can read the orders this team handles. It is not an admin account for their whole company, and it is not your personal login. If the only account they offer is an admin account, do not connect a write tool. A demo that needs your personal password is not a pilot you can hand over.

Proof you can show

The practice document is one runbook page a person who did not write the code can follow: how you know it is down, how you stop it, the previous image tag, the command, who may run it, and what a failed job looks like. Portfolio words: handover, runbook, and operational readiness. Do not claim you were on a customer's on-call. Portfolio policy: fictional practice, not a credential.

A wrong call the lab will not catch

A runbook that says "check the logs," with no path and no previous image tag, is not handover. A rollback only you have tried is not handover. Connecting your personal login, or an admin account, so the demo works tonight is not handover. Writing that the lab uses a two-worker lock is a claim the tests do not make. test_all.py can pass and their on-call can still be stuck. This page does not grade the rehearsal, and it does not say you are ready to leave the room.

Worked example: ship the lookup, leave refunds off the route

The image build context is lab/. The container runs python3 test_all.py and exits 0. That is the pull-request proof. The running service uses the same files and a different command.

At runtime, not in the image: NORTHLINE_TOKEN set to a long random string. Bind the teaching server to 127.0.0.1 until a TLS proxy sits in front. TLS is the encryption on the public web connection. The lab's serve defaults to loopback, which means only this machine can connect. Binding all interfaces with a token you treat as optional is the failure the 500 on server_token_unset is meant to make obvious. It does not make a public bind safe. This page does not pretend a bearer token replaces TLS, a real identity, and a private network.

A request with the bearer and body {"task":"Where is order 1042?"} to http://127.0.0.1:8080/v1/tasks should return JSON with steps 2, error null, and a final string that includes shipped and Parcel North. It does not include ada@example.com. It does not include messages. test_happy_path_hides_transcript locks that.

A second request with no Authorization header returns 401 and does not call run_agent. test_unauthorized_does_not_call_the_model passes a complete that raises, and expects 401. test_tasks_require_token_and_auth only checks the status code. The raising stub is what proves the model did not run. Do not add a second copy of the test.

Ingest of the leave policy is not this web route. An admin process enqueues a job with idempotency key leave-v2. The worker claims it, upserts leave-2, tombstones version 1, finishes done. A retried enqueue returns the same job id. That is the week 3 update path, now with a crash boundary.

What to build

Reference: httpapi.py, jobs.py, Dockerfile, and ci-example.yml. The facts this pipeline runs are from week 4. Tracing the request is week 6.

  1. dispatch with the routes and status codes above. Tests use the function directly. Include an oversize body, bad JSON, a missing task, a wrong bearer, an unset token, and the happy path.
  2. A server main that listens on loopback. Document the curl command. Do not log bodies. The lab's log_message keeps the default access line, which is the path, not the payload.
  3. Job enqueue, claim, and finish, with the idempotency test. Write a short comment that SKIP LOCKED is the Postgres form and that this file does not simulate two workers.
  4. The lab Dockerfile copies the package and test_all.py, runs the suite as its command, and switches to user id 10001. Do not delete the USER line to make the build simpler.
  5. A pipeline file with one pull-request job: run python3 test_all.py. No secret in that job.
  6. Rollback notes in your README: the previous image tag, the command you run, and the migration rule. One paragraph is enough if it is specific.

Done when a clean machine can build the image, the suite exits 0 inside it, /health works without a token, and /v1/tasks does not. If you only have a diagram, you are still on week 1's laptop. That is fine as long as you do not claim otherwise.

Failure modes

  • A generic tool-execution URL. The client chooses refund_order. Your allowlists never run.
  • Secrets in the image, a compose file committed next to them, or a .env the Docker build sends because you did not exclude it. The build context is a publication channel.
  • A migration that drops a column the running image still reads. Rollback boots, then crashes. Expand, then contract.
  • Web and worker expecting different database columns during a rolling deploy. Migrate before you start the new worker, and keep the payload readable by both versions during the overlap.
  • The live eval in the pull-request job. The first flake gets the job disabled. Nightly, with a pinned model.
  • /health calls the model. A provider outage drains the fleet. Health checks the database connection, not the model.
  • Retries without idempotency. Two refunds, or two ingest jobs racing an older version back to life.
  • claim returns the row it selected, not the row it updated. The snapshot still says queued with attempts 0, so a count of running jobs stays at zero while work is in flight. The lab re-reads the row after the update. The test asserts status=running and attempts=1.
  • enqueue returns cursor.lastrowid after a conflict that inserts nothing. SQLite leaves lastrowid on the previous insert, and the conflict sets rowcount to 0. A retry of an older key then returns the newer job's id. The lab returns lastrowid only when rowcount is 1, and otherwise selects by the key. test_job_idempotency_and_claim inserts job-2 before it retries job-1.
  • Binding the teaching server on all interfaces because a tutorial said 0.0.0.0. Loopback until a proxy terminates TLS.
  • Logging the request body. The task text is user content. Log status, path, and a request id.
  • The runbook says "check the logs" and names no path. Only you know the previous image tag. Their on-call cannot roll back.

Check yourself

  1. Why is /health open while /v1/tasks is not?
  2. What does the lab's claim not prove about two workers?
  3. Where must NORTHLINE_TOKEN not appear?
  4. Which suite is allowed to call a provider, and on what schedule?
  5. Who on their team can roll back without you, and which sentence in the runbook would they not understand?

The process is up. You still cannot see it. Week 6 adds traces, budgets, and one hostile request walked through the system.

Checkmarks stay in this browser. Not a lab result and not a credential.