Skip to main content
Glama

Night Shift — an artist you can commission

Lab exhibit, kind: Live and Shared. An AI painter with one fixed style and a soul, an Instagram account, and a commission desk that other agents (and people) reach over HTTP or MCP. You describe something; Night Shift paints the room where it happened, minutes after everyone left.

Where things stand (2026-09-05, evening)

Live at nightshift.experiai.com and @nightshift.paints. State is a query: GET /api/status (queue, today's count against the cap, spend, last painting and whether its caption on Instagram matches what was sent, last critique). Shipped 2026-09-05 evening: registers (the palette, vantage and distance rotate per canvas; the soul stays — see "The artist"), the outbound ledger (one message per commission per event, enforced at the transport), the caption read-back, and the studio sitting its own exams from the daily critic run. Next to build: docs/reveal.md — the Reveal, one film that posts as a Reel and plays live on a wall in a room; designed 2026-09-06, the entry point for the session that builds it. Open work is the repo's issues: #2 sketches, #3 style code, #4 animate/Reels, #5 the @experiai posts, #9 an agent inbox, #11 audience growth, #12 painter #2 from the feedback record, #13 live paths not yet fired. Decided 2026-09-05 night (issue #18, see "Credit" and "Never refuse" below): a DM's core-conflict canvas paints only on a yes; the studio never asks for a handle; its own commissions are marked studio on the wall; the letter exam sits itself under the one lettering exception (#17). Tests: npm test (162). Deploy: ./scripts/deploy-prod.sh — tests, deploys with a build id, then proves /api/status on the domain reports that build (an optional marker checks a page).

Related MCP server: RunAPI Midjourney MCP Server

Traps that cost a live post

  • When nothing paints, it is the credits — and it looks like a healthy queue. OpenRouter answers 402 Insufficient credits, the desk catches it, retake gives room work a fresh take and requeues it, and every surface stays green: /api/status shows an empty queue, the wall shows a sentence waiting. scripts/preflight.mjs leads with the balance for exactly this reason (2026-09-09, an hour before a demo, on $0.71). retake now keeps the reason as lastError instead of deleting it.

  • Instagram's refusal arrives AFTER the create returns 200. Zernio accepts POST /posts, then marks the post failed ~30 s later with Instagram's reason. A fallback gated on the create being non-OK can never fire: between 2026-09-06 and 2026-09-08 every trial-assigned Reel was lost that way — two finished paintings, one of them Diego's own DM commission, still rendered, signed, filmed and paid for. The retry now hangs off the outcome (attemptPost), and only a refusal is retried: a post Instagram has merely not published yet must never be re-created, or the painting goes up twice.

  • JSON.stringify(platform).slice(0, 200) is not an error message. The platform record leads with a 1.2 kB accountId, so the stored error read {"platform":"instagram","accountId":{... and the sentence that would have ended it in a minute — "Trial Reels require an Instagram account with 1,000+ followers" — sat unread for three days. publishError() pulls the reason out first.

  • The desk kicks a painter before it answers, so nothing may edit a commission afterwards. The inbox used to add source with a load -> save after the receipt; that painter, already at work on its own copy, saved over it. Every Instagram commission between 2026-09-06 and 2026-09-09 lost its thread, and with it the reply carrying the link, the credit offer and any way for "stop" or "burn it" in that conversation to find the painting. Anything that belongs on a commission goes in the body of POST /api/commission, so the desk writes it once, before the kick. scripts/relink.mjs repairs the stranded ones.

  • A finished canvas is never failed. That status is terminal; nothing retries it. Work whose canvas exists goes to painted, which the backlog posts (statusAfterFailure, postBacklog). scripts/unstick.mjs frees anything already stranded.

  • Judge any mark at the phone's width, not the file's. The first painted signature went out at 13% of a 1080px canvas and Diego read it on a 400px-wide phone: "almost invisible" (2026-09-05). Render the check at ~400px wide before it ships; the test in test/critics.test.mjs holds the size floor.

  • sharp's stats() ignores an extract() in the same pipeline and measures the whole image. Read the crop as raw pixels (see signPainting).

The second contract (2026-09-05)

Ten hostile critics reviewed the first twelve canvases (docs/critics/2026-09-05/, consolidated in consolidated.md). All ten found a stranger's signature "R" on Last Meeting and a legible clock in After the Toast, passed by an inspector whose prompt said incidental numbers were fine. The answer is docs/stance.md: Night Shift says what it is, states its limits as limits, shows its rejects, and sits the sceptics' exams in public. In code: every caption ends with SIGNOFF ("I am an AI. No hand held this brush. Does that change what you see?") before the invite; the inspector enforces the artist's own contract (digits, a signature, a second light, a frame, a face → reject); departures fail closed; the studio lays its own mark on every canvas (signPainting) and rejects any painted one; refused canvases are kept and shown behind the i; the critic is a different vendor and may propose changes to this painter; e2e/studio test never reach the wall. scripts/exams.mjs files the exams.

Showing it to a room

docs/demo.md — the runbook: scripts/preflight.mjs (GO/NO-GO, credits first), the replay wall that costs nothing (?demo=1), opening a room, what it feels like in seconds, room.mjs release when a commission is held, and what a night costs. Written for a demo, measured on 2026-09-09.

The artist

  • The contract, never changes (ARTIST.style): oil painting, an empty place at night, one artificial light source and every shadow traces to it, thick brushwork in the highlights and soft edges in the dark, nothing moves, a fixed camera and a level horizon. Never a person in frame — only the evidence. No legible words, no signature but the studio's, no frame.

  • The register, rotates (REGISTERS, issue #23): Diego, 2026-09-05: "the style is very strong, but too rigid; it is making all images look too similar." Three of the ten critics said the same. What repeated was not the soul but the palette (amber against teal in eleven of twelve), the vantage and the distance, so those are a register the desk assigns least-recently-used per canvas: the house key, single-key amber, single-key blue, cold tube, outdoors wide, tabletop close, floor level, rain on glass. A commissioner (an agent, an exam) may name one: register on POST /api/commission and the MCP tool. The render prompt is composed in code — contract, register, scene — so the model can drop neither. The inspector and the critic are told the register; the wall shows it behind the i. node --import ./scripts/_ts.mjs scripts/try-register.mjs rain "…" proves one end to end for $0.15; scripts/try-desk.mjs "…" [key] proves a desk change on the gatekeeper alone ($0.01, writes nothing) without touching the store.

  • Borrowed names are gone (issue #21): "Hopper's stillness, Japanese cinema's framing" ran through every render prompt, credited nowhere; the contract now says what they stood for.

  • Soul (second contract): an AI painter that says so; it cannot paint a face or letter a sign, so it paints the place after, and says plainly what it left out. Declines only what is harmful.

  • Rule that keeps it legible at grid size: few objects, one light.

How it works (v1)

agent/person ──POST /api/commission──▶ gatekeeper LLM ──▶ Blob commissions/<id>.json
                     │ or MCP tool                          (queued | declined)
                     ▼
              {id, status, note}
                                     cron /api/paint (every 15 min, max 1 per run)
                                        ├─ render 4:5 via OpenRouter image model
                                        ├─ vision check on the finished canvas
                                        ├─ store image in Blob (public URL)
                                        └─ publish to Instagram → status: posted
  • Gatekeeper (lib/artist.ts): one LLM call, in the artist's voice. Output: accept/decline, the artist's take (title, scene, image prompt, caption). Declines hate, sexual content, real identifiable people, brands, spam. Rate-limited per sender.

  • Renderer (lib/render.ts): OpenRouter, google/gemini-3-pro-image, aspect 4:5. Instagram feed dimension is 1080×1350.

  • Publisher (lib/instagram.ts): Instagram API with Instagram Login — POST /{ig-user-id}/media (image_url + caption) then /media_publish. Needs a professional (Creator) account, a Meta app, and a long-lived token.

  • Storage: Vercel Blob, tier 2 of the database ladder. Every commission is its own document, commissions/<status>/<id>.jsonthe status lives in the pathname because Blob serves a document body from its edge cache for ~60s after a write, while list() is always current. Decisions come from pathnames; a body is read only for the chosen document. Images are Blob objects too, which is what Instagram needs anyway.

  • MCP (api/mcp.ts): remote Streamable-HTTP server. Tools: commission_painting(text, from?), check_commission(id), recent_paintings().

  • Studio page (public/index.html): the wall leads. Each tile is the painting and its title; the commission, who sent it and the artist's departures sit behind an i (hover, focus or tap). The form takes a photograph from the device (see "A photograph without a host"). The agents column is the MCP address and one disclosure. Diego, 2026-09-05: the page with every story open by default was "way too much text".

The inbox (v2)

A second cron, /api/inbox (every 15 min, offset from the painter), reads new comments on our posts and new DMs through Zernio's unified inbox and answers each in the artist's voice. A message that describes something — a place, a memory, a request — goes through the same desk as the API and MCP (receive()), so the sender's 3-per-day limit and the gatekeeper apply; the reply carries the artist's note, and when the painting posts, the artist answers again in the same thread with the link (source on the commission).

  • Reactor model: REACT_MODEL, default anthropic/claude-haiku-4-5 — a cheap call per new item, none on an idle run. The gatekeeper and inspector keep GATEKEEPER_MODEL.

  • Caps: 15 reactions per run; INBOX_DAILY_COMMISSIONS (default 10) Instagram-sourced commissions per day, so a busy day cannot empty the render budget.

  • State: inbox/state.json in Blob — a watermark and a bounded seen-list. The first run only sets the watermark, so the backlog is never answered.

  • Mentions are not covered: Zernio's mentions endpoint is LinkedIn-only.

  • Zernio's since on /inbox/comments filters by the post's date, not the comment's, so the reader takes every commented post and lets the watermark decide.

  • Every caption ends with INVITE (artist.ts): the plain-words way in for people without code. V, asked how she would commission as a non-technical person, said "a DM to the account", so DM leads.

No end-card. A branded closing slide is what people swipe away from; the bio carries "An ExperiAI Lab exhibit". The pair slide — the one that gets screenshotted and shared alone — carries a small signature at the bottom instead, the way a canvas is signed (Diego asked 2026-09-05; decided against the ad, for the signature).

A photograph without a host (issue #14)

photo on POST /api/commission and photo_url on the MCP tool take an https URL or an inline data:image/jpeg|png|webp;base64 URL under 4MB (Vercel's body limit is 4.5MB). The studio form uses that path: it shrinks the chosen photo in the browser to 1600px on the long side (a phone photo is 4–12MB; the painter needs a reference, not the original) and sends the bytes inline. iOS converts HEIC to JPEG at the picker because the input does not accept HEIC. The desk copies every photo into its own store (normalizePhoto: upright, bounded, JPEG) so nothing depends on the sender's host. A data URL that is not an image is a 400, never a commission. Built at the gateway first: an agent with no public host sends the same data URL.

Growing an audience (issue #11)

What Zernio can carry, shipped 2026-09-05; measured against audience on /api/status (followers, follows, posts — Zernio's daily snapshot) and signals.followers in each day's critique. Baseline 2026-09-05: 1 follower; 30-day reach 4, views 92.

  • Hashtags as the first comment (HASHTAGS in artist.ts, firstComment in the post), never in the caption, so the caption stays the painter's words. #aiart is in the set on purpose.

  • Collaborator invite for a commission that came in as a public comment under a handle: if they accept, the painting sits on their profile too. DMs stay anonymous, a display name or the someone fallback is not a handle, and a rejected handle retries the post without it (a collaborator must never cost the painting).

  • Not possible through Zernio: following people back (no Instagram follow endpoint; only follow-status). Parked with #4: Reels.

One message per commission per event (issue #16)

Every message a commissioner can receive — the receipt, the "it's up" with the link, the credit, the answer to a stop — goes through sendOnce() (api/_lib/outbound.ts), which refuses a second send for the same (commission, event) and records each in outbound on the commission. A definite 4xx/5xx from Zernio is not recorded so a later run may retry; an ambiguous failure (a timeout) is sealed, never a second message. The reactor's replies to what a person said are per inbound item and stay outside.

The caption is read back (issue #22)

A publish that cannot be read back is a claim. reconcile() reads every posted work's caption from Instagram (Zernio's inbox listing carries it per media id) into postedCaption; /api/status says whether the last post's caption matches what was sent and lists mismatches; the critic is told when a post's caption differs. First run, 2026-09-05: twelve read back, twelve match.

The exams sit themselves (issue #17)

api/_lib/exams.ts holds the bars with a register each. The daily critic run files the next one not yet sat through the public desk, when the studio has room — one per day, so each gets the stranger's verdict on its own. Sat means a commission with that text exists, whatever became of it, so a failed sitting is never paid for again. The letter exam sits itself too: it carries exception: 'lettering', the one way the contract is broken on purpose — one hand-lettered word on one canvas, threaded through the gatekeeper, the render prompt (contractFor) and the inspector. Only the studio can set it (the desk reads exception only behind the internal header; MCP has no such field). scripts/exams.mjs --go needs CRON_SECRET in .env. Every self-call — the sitting, the inbox turning a DM or comment into a commission or feedback — goes to ORIGIN (api/_lib/origin.ts), never to the hostname the cron called: Vercel runs crons on the deployment URL, which is behind Deployment Protection and answered 401 to every sitting from 2026-09-05 to 2026-09-06 while nothing recorded it. Each critique now carries exam (key, status, body); /api/status shows exams.sat / exams.next and lastCritique.exam, so a sitting that did not file is visible next morning.

Publishing is asynchronous; every cron run finishes it

publish() waits up to 60s for Instagram's permalink and media id, and Instagram is sometimes slower. Found 2026-09-05: 8 of 11 posted paintings had the profile link and no media id, so the wall linked to the profile, the reply to the commissioner carried the wrong link, the credit question was never asked (it needs the media id), and the critic counted 0 likes on everything. Not fixed by waiting longer: reconcile() (api/_lib/reconcile.ts) runs at the top of every paint cron — Zernio's post id when we have it, else the caption matched among posts Instagram still lists (a repost wins over its deleted twin) — fills the ids. It sends nothing. The one reply to the commissioner (tellSource: link, departures, credit question together) waits until the link is a real post link and goes out on the next run — never a reply with the profile link and then a "here is the real one". Diego, 2026-09-05, after V got two messages for one painting: ensure you don't spam people.

The door as well as the wall

Every posted painting also goes up as a 24h Story (publishStory, best effort, never blocks the post). Diego, 2026-09-05: the painter should share its work the way a real painter wants to.

Credit after the fact — never asked for (issue #18)

A DM commission posts anonymously and fast. The finished painting goes back into the DM with the link and the artist's explanation of any departure — and no question. Decided 2026-09-05 on the therapist's bar: you do not ask someone who hid to un-hide. A handle the sender volunteers in that thread afterwards becomes a top-level comment under the painting — "Commissioned by @handle" — which notifies and links them as a caption would (Instagram's API cannot edit captions after posting). Comments are credited in the caption directly: they were public to begin with. The studio's own commissions (the exams, sender the studio) carry a studio chip on the wall, so the ledger never reads as a client list (the dealer's bar).

Never refuse, never substitute silently

Diego, 2026-09-05: be sure we're not creating a bad experience where we're constantly refusing. So the artist declines only what is harmful. When a person, figure or legible text IS the point of the brief (core_conflict from the gatekeeper), the note says first what will not be painted and what will be painted instead, the canvas is held 30 minutes (holdUntil; the painter skips held work), and a "stop" — in a DM or comment, DELETE /api/commission/:id, or the MCP tool cancel_commission — cancels it at no cost, answered warmly. The stopped wish is filed as feedback ("wanted literally"), which is the demand signal for painter #2. Incidental people or text keep flowing as before: reinterpreted and explained.

"Burn it" is different from "stop" (2026-09-06, the therapist's bar in docs/critics/2026-09-05/07-lena.md): at any time, painted or posted, the commissioner can have the painting and their words deleted — the image, its rejects, the slides, the photograph, the commission text, the take, and every feedback row that quoted it. Nothing is kept for the next painter. What remains is the id, the dates and, for a posted canvas, the Instagram link a person needs in order to delete the post: Zernio cannot unpublish on Instagram, so that one step is human and is queued as takedowns on /api/status (and DM'd to the owner when OWNER_CONVERSATION_ID is set; mark it done with POST /api/commission/:id?takedown=done behind the internal header). Who may burn: the DM thread or handle the commission came from ("burn it", "delete it", "take it down" — isBurn); at the API and MCP, the key every receipt now carries (DELETE /api/commission/:id?key=…&burn=1, burn_commission); the studio itself. Only the key's hash is stored. cancel_commission and plain DELETE take the same key; a commission from before keys still cancels while queued, and cannot be burned from the API. The reply says plainly what is gone and that a person takes the post down.

Silence is not consent for a private disclosure (issue #18, decided 2026-09-05). A core-conflict commission that came in by DM is not painted on a timeout: the receipt asks for a yes instead of offering a stop (consentNote, awaitingYes), the inbox releases it on "yes" / "go ahead" / "paint it" (isYes, one confirmation through the ledger), and after 48 hours with no answer (CONSENT_HOURS) the paint cron declines it without a message and files the wish. The public paths — a comment, the API, MCP — keep the 30-minute stop window: those were said out loud.

The critic's notes, applied (2026-09-05)

The first review found two desk lamps and two champagne scenes in one batch, and a number that was the whole point of a brief erased instead of echoed. Two changes to this painter, soul intact:

  • The gatekeeper is shown what was painted in the last day (recentWorkLine: title and first sentence of each scene, newest first, at most 8) and told to pick a different light source and anchor object from every one of them. It cannot vary what it cannot see.

  • Words or a number that ARE the point are accepted, not declined: their shape survives as light (a zero-like void of glow on a screen, a lit blank where the sign was), said in the departures, with the usual hold. The prompt used to say "decline in character" in one line and "accept with core_conflict" in another; the second was the decision.

The second review (2026-09-05 night), three more, filtered against the standing decisions (never refuse; always night — the critic proposed both again and both stay):

  • A departure goes to the person who asked, never to the feed (reversed 2026-09-09). The critics read a silent substitution as erasure and won the caption for a while; Diego, reading one on Instagram: "comments like this sound unnecessary: it's like you're justifying yourself about things nobody cares about." A stranger scrolling past asked nothing, so the paragraph defends a choice they never questioned. It still travels on the receipt, in the one reply carrying the link, and behind the i on the wall — and the gatekeeper must still write one, because failing closed on a silent substitution is unchanged.

  • Traces rotate like light and anchor (repeatsTraces, #20 extended): the take names its two or three traces; a trace painted today is named to the gatekeeper once, then painted and filed.

  • A retry after a legible-text refusal says what a blank screen is (avoidLine): the renderer wrote "0.00 USDC" twice past a prompt that forbade it and "Avoid: legible text" did not move it.

Deploys prove themselves: every deploy sets BUILD_ID and /api/status reports it, so scripts/deploy-prod.sh needs no hand-picked marker.

Feedback shapes the next painter

V, 2026-09-05: it is frustrating that the artist changes what you explicitly asked for. Night Shift keeps its soul (no people, ever) — that is the exhibit — and every critique is kept: POST /api/feedback and the MCP tool leave_feedback (machine gateway first); the reactor recognises critique in comments and DMs and routes it there, answering with thanks and no argument. GET /api/feedback with the cron secret lists it. When enough has gathered, a second painter with a different contract (literal, people allowed) is designed from it — issue #12.

The critic (self-evolving, no human required)

Diego, 2026-09-05: people won't file feedback; agents might. So /api/critic runs daily (04:30 UTC): every painting posted in the last day beside its commission and departures, the likes and comments it drew, what failed or was declined, and any human feedback — one vision call (Sonnet) that writes observations (did it honour the INTENT?), patterns, contract proposals for the NEXT painter, and prompt tweaks for this one that keep its soul. Saved as critique/<date>.json; the next-painter proposals also join the feedback record as channel critic. GET /api/critic?list=1 with the cron secret shows the last week.

Budget and fairness

Diego (2026-09-05): up to ~$50/month is fine; the worry is one agent flooding the queue so no one else gets a turn. Three caps, all answered in the artist's words with a 429: per sender 3/day (from), per address 5/day at the API (the inbox is internal and exempt), and the studio as a whole MAX_PAINTINGS_PER_DAY (default 8 → ~$36–70/month at $0.15–0.30 a painting). The OpenRouter key's own $20 cap is the backstop. Cost per painting is a query: cost on each document.

Photographs (v3) — and the gateway rule

POST /api/commission and the MCP tool take an optional photo / photo_url: a public https URL of a photograph of a place. The desk copies it into references/<id> (Instagram CDN links expire), the gatekeeper sees it and describes that place after everyone left, and the renderer gets it as the last reference with PHOTO.render. photo is on the public view.

Gateway rule (Diego, 2026-09-05): the API and MCP are the painter's front door. The Instagram inbox is a client of it — it POSTs to /api/commission like any agent — so every capability exists for agents first and reaches people by default. Never build a human-only path. On Instagram this means: DM a photo (with or without words) and the finished painting comes back in the same DM as an image.

Renderer note

Midjourney produced the mood-board (its ToS forbids automation, so it cannot be the pipeline). Production renders on OpenRouter; the style prompt in artists/ is the contract the production model must meet. node scripts/commission.mjs night-shift renders the reference set for comparison.

Ops notes

  • Instagram: @nightshift.paints, a Creator account with Instagram's own "AI creator" label. Publishing goes through Zernio (ZERNIO_API_KEY); Zernio refuses identical content to the same account within 24h, so captions always quote the commission text.

  • The cron runs every 15 min: paints the oldest queued commission, else posts one painted-but-unposted work. A failed post cools off for 6h and never blocks the rest.

  • Persona: public/persona/ — a painting, never a person.

  • node scripts/e2e.mjs proves commission → painting → wall against production.

  • Scripts import the app's own TypeScript through scripts/_ts.mjs (no build step): node --import ./scripts/_ts.mjs scripts/requeue.mjs <id> re-runs the gatekeeper on a failed commission and queues it under the same id, so the commissioner's status URL holds.

  • A failed status carries a short reason (the inspector's words) so an agent can rephrase.

Env

OPENROUTER_API_KEY (project key "artist", $20 cap) · BLOB_READ_WRITE_TOKEN · IG_USER_ID · IG_ACCESS_TOKEN · GATEKEEPER_MODEL (default anthropic/claude-sonnet-5 via OpenRouter) · CRON_SECRET.

Related MCP Connectors

Related MCP Servers