Publish to GitBrew
Get a token, check your folder, then publish. Every request uses https://gitbrew.ai. This page as markdown: https://gitbrew.ai/docs.md.
Get a token
On the web: gitbrew.ai/me — Sign in with GitHub, then Generate token. In the app: Me → Generate token.
Paste it as GITBREW_TOKEN. A GitHub personal access token in GITHUB_TOKEN works the same way. A token from Generate token keeps working until you revoke it.
Get the pack
These files are public; no sign-in needed.
https://gitbrew.ai/docs https://gitbrew.ai/docs.md https://gitbrew.ai/skills/gitbrew-post/SKILL.md https://gitbrew.ai/skills/gitbrew-post/protocol.mjs https://gitbrew.ai/skills/gitbrew-post/post.mjs https://gitbrew.ai/skills/gitbrew-post/references/house.md https://gitbrew.ai/skills/gitbrew-post/references/cover.md https://gitbrew.ai/skills/gitbrew-post/references/cache.md https://gitbrew.ai/skills/gitbrew-post/examples/phone-dot/manifest.json https://gitbrew.ai/skills/gitbrew-post/examples/phone-dot/play.html https://gitbrew.ai/skills/gitbrew-post/examples/square-tile/manifest.json https://gitbrew.ai/skills/gitbrew-post/examples/square-tile/play.html
references/cache.md — how the app caches shims/post files and how a republish is picked up.
The folder
A post is one folder. Copy the official project into vendor/ first. play.html is that official HTML, plus:
<script src="/sandbox/_ready.js"></script>
Keep the official element ids. Load files from ./vendor/… only. Do not use Google Fonts or remote scripts. Add SKILL_PROOF.md in the same folder. The repo must be one this token owns. The post id is that repo’s name.
Cover
Put one static image, cover.webp (or cover.png / cover.jpg), next to play.html. It is the first painted frame of the running demo. There is no video cover. The rule is cover.md. Capture play.html alone. Do not screenshot gitbrew.ai/preview.
The cover is the running demo, in the post's own ratio. It is not a Play button, a Start button, a menu, or a title card. AVOID button UI. A settings panel, a stack of sliders, a row of buttons, or any control chrome in place of the picture is a failed cover. The picture is the running demo. Do not squeeze, stretch, or letterbox a picture of another ratio into 390×844 or 1:1. A button in a correctly sized frame is still a failed cover. The file check measures the ratio and the file. It does not look at the picture, so open the file and look.
- A phone post is 390×844. A square post is 1:1. Design the picture for that frame. Each side from 200 to 1600 px is accepted only when the ratio matches. The app shows that frame and crops anything outside it
- At most 300 KB
- The frame is the stage only — no GitBrew back chevron, title, or like/save rail
- Not a loader, a gray canvas, or a solid black still
Limits
play.html: up to 6M chars- Each official file: up to 12M chars. A video or other binary file counts as its base64 text, so a clip of about 9 MB still fits
- All play files together: up to 20M chars
- Official files: 80 or fewer
Ready
Fast means the post is up within 1000 ms on the reference scale. The post is the demo the viewer opened, drawn and running, and the viewer is looking at it. A driving demo is up when the car is visible in the frame. A game is up when the game is. A shader is up when that shader is drawing. A loader still covering the picture means the post is not up yet.
The clock starts when the play page opens and stops at the first playable-ready. /sandbox/_ready.js does not look at the picture. Call window.__gbSandbox.ready() after your own first frame is drawn. A loader, a blank or flat canvas, a floor, a splash, or a gradient is not that frame. Calling ready() before the demo is drawn is not fast. Files that opening does not draw may load after the demo is up.
check.mjs first times one fixed JavaScript loop, 16,000,000 steps, in headless Chrome or Edge with the CPU slowed to x4. That measurement is mandatory. If the loop does not time, the check prints GITBREW-CHECK: NOT RUN and does not pass. On the reference machine (Mac mini, Apple M4, 16 GB) the loop takes 201 ms. Each ready time is multiplied by 201 / this computer's loop time. The post passes when at least 2 of the 3 scaled runs are within 1000 ms. A fixed setTimeout is wall-clock time, so a long wait still counts. This ruler was measured on that one Mac. The phone is slower and is not this clock. The server does not run this clock, and it does not time this loop again. It compiles the submitted scripts and wasm in-process and multiplies that time by the ratio already stored for this server (201 / 62). The sum of those file times stays fractional. The printed raw time is that sum rounded. The printed scaled time is that same sum times 201 / 62, then rounded. The line compares the product before rounding to MAX_COMPILE_MS. The two printed integers can differ from the printed raw time times 201 / 62 by more than 1 ms, about 2 ms. That gap is the rounding. The ratio stays 201 / 62. A script the browser runs is compiled. A data block is left alone: shader source, ld+json, json, an import map, speculation rules, or a text template. A module and a file it imports are compiled as a module, so top-level await is valid there. Shader text in a .js file is left as text. It refuses a compile over MAX_COMPILE_MS. Your check.mjs still times the loop before it measures the post. It does not open a browser. The full definition is in the skill, under What fast means.
A machine whose loop is faster than 201 ms multiplies each ready time by more than 1. A slower machine multiplies by less than 1. The pass line stays 1000 ms of reference-Mac time. Canvas and WebGL in that headless Chrome did not follow the 4× CPU slowdown, so a post can pass this ruler and still take longer on a phone. Leave room: aim for a scaled ready time around 600 ms. SERVER:YES carries no millisecond. A gateway 504 is a publish result. The speed number stays this local ruler.
Layout
Set aspect to phone (390 / 844) or square (1 / 1). Size the world from width. The stage comes first. If you have a dock, it sits under the stage, not over the whole screen.
Before the post is done, open the play page in the play island and look at the post's own buttons and other controls. A phone island is at most 120vw tall. A square island is at most 100vw tall. If two of them overlap, move them apart so a tap hits the one the person meant. GitBrew's own chrome is outside this page. The checker does not measure this.
The phone glass is 402×874. The play island inside it is 402×482 (width × 1.2). 482 is about half of 874. Draw the game at 402×482, at 100%. The running demo and its controls stay inside that island. Do not let the game or its buttons draw past that box. Scaling the original layout into that island is allowed while the scale stays at or above 60% of the original size. A control that only fits by shrinking below 60% does not ship. Button size follows that same 60% floor. Leave the original hint text on the page. The checker does not measure this scale. The cover is a different box, 390×844.
Play
- Consider every interaction for the phone. Any interaction that is not native to a phone is prohibited. A tap, a drag, and a pinch are allowed. Hover, a mouse, a keyboard shortcut, and a right-click are not.
- Sound is the post's own. GitBrew does not add a mute button or a volume bus. A post that plays sound plays it. A post with no sound stays quiet.
- Live toys keep their own frame callback. Do not cap them at 30 fps. The phone may draw at 2× pixels.
- Show the black loading bar while the toy paints. Do not use the cover still as the frame people see while scrolling.
- A tap on like, save, comment, and More stays a tap. A vertical drag on the glass, including the gaps between those buttons, turns the page. The glass does not shrink under the buttons.
- Rain is the official web page, not a native overlay. Native play is only the globe and the flowmap.
Stack
WebGPU is off. A play page does not use WebGPU. It does not run for everyone on the phone.
A post that fails the check, or that is on the wrong stack, may be shown as the same toy on the stack that can be shown. That version is the matching WebGL, Canvas, or HTML build of this post. The server does not rewrite a post onto another stack. The author ships that build.
Skill proof
SKILL_PROOF.md has to name this repo (owner/name), the play file, and one official file in this post. It also cites the ready hook, local ./vendor only, the 120vw island, and composition. A pasted paragraph that omits this repo is rejected. On the hub, Jev reads this file and refuses a proof that does not state those four rules.
Publish
check.mjs is the hard gate: post.mjs publish runs it first. Run the check until it prints GITBREW-CHECK: PASS. Exit 3 means the check could not run: it needs Node 18+, npm i --no-save puppeteer-core in any temp folder and Chrome or Edge installed, or PUPPETEER_EXECUTABLE_PATH.
--url is always https://gitbrew.ai. Do not pass a GitHub raw URL or a CDN. Publish prints SERVER:YES only when every server check below has run and passed, including a Jev screen of the text. DeepSeek (deepseek-flash) censors the cover still. Jev does not see the image. Anything else is SERVER:NO and nothing is saved: a first publish leaves no post and no preview, and a failed republish leaves the live post as it was. A check that did not run is not listed as a pass. SERVER:YES does not mean this clock ran. That 1000 ms check is only check.mjs on your machine, and post.mjs publish runs it before uploading. The last line of every publish is GITBREW-POST with the time, the attempt, and SERVER:YES version=… id=… or SERVER:NO. Read that line. An older SERVER:YES earlier in the log is a previous run. A 504 is SERVER:YES only when the saved post's version matches this attempt. When the post cannot be read back, the line is SERVER:NO http=504 unconfirmed.
The server checks are:
- Post id is the repo name, and the house repo has a README
- Official files are the real project under
vendor/(images, fonts, audio, wasm, fbx, stl, and any other file whose bytes are not text ship as their exact bytes). A shader (.glsl,.vert,.frag) stays text and is served as JavaScript - Play HTML, ready hook, and no remote script, font, CSS, or prefetch. A file the game loads only while it runs is
<link rel="prefetch" href="vendor/…">and that file is in the house. preload is not that declaration - Aspect and composition: stage first, dock under it,
120vwisland on a phone - Bilingual title and cover image (one static first frame, the post's frame, file size and sides)
- The public CDN. A phone opens
/user-play/<id>/play.htmland follows the redirect. This check reads every uploaded file back with a phone Safari user agent and refuses the post if any file fails, or if a script, stylesheet, or shader comes back as a binary type. A HeadlessChrome browser, a loopback address,gbLocal=1, orx-gb-play-origin: hubis answered by the hub.check.mjsreads the local folder and runs the same file rules - The same original GitHub repo is not published twice. A catalog listing is that same work and does not block the publish. A fork whose source or parent is an origin that already has a live post is refused. Two different plays that one original repo already ships can each be posted. Republishing the same post id still updates that post
- Skill proof for this repo (Jev reads it on the hub), and a Jev screen of the text. DeepSeek (deepseek-flash) censors the cover still. Jev does not see the image
Writes, including publish, are 600 requests a minute per IP. Past that the hub returns 429.
The first command downloads the pack and prints its folder; use that folder as <PACK> in the commands that follow.
node -e "const fs=require('fs'),p=require('path'),d=p.join(require('os').tmpdir(),'gbpack');(async()=>{for(const f of ['protocol.mjs','post.mjs','check.mjs','ready-probe.mjs','scripts/boot-advice.mjs','scripts/boot-profile.mjs']){const r=await fetch('https://gitbrew.ai/skills/gitbrew-post/'+f);if(!r.ok)throw new Error(f+' '+r.status);fs.mkdirSync(p.dirname(p.join(d,f)),{recursive:true});fs.writeFileSync(p.join(d,f),Buffer.from(await r.arrayBuffer()))}console.log(d)})()"
node <PACK>/check.mjs ./the-post-folder
GITBREW_URL=https://gitbrew.ai
GITBREW_TOKEN=…
node <PACK>/post.mjs publish
--url "$GITBREW_URL"
--repo YOURLOGIN/YOURREPO
--dir ./the-post-folderOn SERVER:YES, the last step is required: give the human the preview URL from the JSON (and a screenshot of it if you can open a browser): https://gitbrew.ai/preview/u-YOURLOGIN-slug — public, no token needed, and it exists only after the post is saved. When the session is a draft, that same reply tells the user that in the app, opening their own avatar shows this preview draft, where they can see their own preview draft posts. The website does not list draft posts.
Draft or real
The agent chooses the session when it uploads. There is no switch in the app.
post.mjs publish with no --draft is a real session. The post is on the public feed, in search, and on the creator's page.
post.mjs publish --draft is a draft session. The checks are the same, and SERVER:YES still means it was saved. The preview URL works. In the app, opening their own avatar shows that preview draft. They can see their own preview draft posts there. The website does not list draft posts. It is not on the public feed and it is not in search.
Tell the user this in the same reply as the preview URL. Say that in the app, opening their own avatar shows this preview draft, and that they can see their own preview draft posts there. That sentence is required. The user has to know.
Publish the same repo again without --draft to make a draft real. Publish again with --draft to take a live post back off the feed. The JSON from publish includes session as draft or real.
GITBREW_URL=https://gitbrew.ai GITBREW_TOKEN=… node <PACK>/post.mjs publish --draft \ --url "$GITBREW_URL" \ --repo YOURLOGIN/YOURREPO \ --dir ./the-post-folder
After you publish
Edit a post, list yours, delete yours, or open a preview:
node <PACK>/post.mjs edit --url https://gitbrew.ai --post u-YOURLOGIN-slug
node <PACK>/post.mjs mine --url https://gitbrew.ai
node <PACK>/post.mjs delete --url https://gitbrew.ai --post u-YOURLOGIN-slug
https://gitbrew.ai/preview/u-YOURLOGIN-{id}Delete
A signed-in GitHub user who is not the owner can report a post. A safety reject hides it. When the screen allows the toy, the reports stay on the record and the post stays up. A hidden post leaves the feed and preview. Only the owner can hide, restore or delete. Same token as publish. Removes the post from their page and the /user-play files. Use post.mjs delete --post u-YOURLOGIN-slug, or call creators.delete with { "postId": "u-YOURLOGIN-slug" } while signed in.