Skip to content

feat(nanocodex): run nanocodex as a durable Rivet Actor - #333

Draft
eersnington wants to merge 3 commits into
mainfrom
nanocodex-actor
Draft

eersnington wants to merge 3 commits into
mainfrom
nanocodex-actor

Conversation

@eersnington

@eersnington eersnington commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Adds @rivet-dev/nanocodex: the nanocodex coding agent running inside a Rivet Actor, with its conversation in the actor's SQLite.

import { nanocodex } from "@rivet-dev/nanocodex";
import { daytonaProvider } from "@rivet-dev/sandbox-adapter/daytona";
import { Transport } from "nanocodex/node";

export const coder = nanocodex({
	transport: () => Transport.openAi({ apiKey: process.env.OPENAI_API_KEY! }),
	sandbox: daytonaProvider(),
	agent: { instructions: "Make focused changes and verify them." },
});

// client
await client.coder.getOrCreate(["issue-42"]).turn.prompt({ id, input: "Fix issue 42" });
  • nanocodex() wraps nanocodex's Agent.create. transport returns nanocodex's own Transport, agent takes its own Agent.create options, the actions mirror its turn.* and session.* methods, and app actions get the agent as c.nanocodex.
  • The conversation lives in nanocodex's durable journal in c.db. Prompting a finished turn id returns its result without a model call; prompting a running id joins that turn.
  • On sleep, running turns finish within the grace period. A turn cut off at the deadline stays in the journal and resumes when its id is prompted again.
  • sandbox takes any @rivet-dev/sandbox-adapter provider. The model gets nanocodex's own file tools through its workspace interface, plus a one-shot exec_command.
  • chatGptSubscription() runs on a ChatGPT subscription held by a chatGptCredentials() actor, picked per agent like pi({ credentials }). nanocodex signs in and refreshes; the credentials actor stores the credential state and sends each token refresh once, because a refresh token rotates.
  • scripts/check-boundaries.mjs applies the pi package rules to the new package.
export const chatgptCredentials = chatGptCredentials({ secret });

export const coder = nanocodex({
	transport: chatGptSubscription((c) =>
		c
			.client<Registry<{ chatgptCredentials: typeof chatgptCredentials }>>()
			.chatgptCredentials.getOrCreate([c.key[0]], { params: { secret } }),
	),
});
flowchart LR
  A[conversation actors<br/>one subscription handle per process] -- "load / compareAndSwap<br/>/oauth/token" --> B[chatGptCredentials actor<br/>credential state in SQLite]
  B -- "one refresh per token" --> C[auth.openai.com]
Loading

Security

  • chatGptCredentials() accepts only callers that pass its secret connection parameter. Conversation actors read the stored credential state, including the refresh token, through that check.

@railway-app

railway-app Bot commented Oct 6, 2026

Copy link
Copy Markdown

This PR was not deployed automatically as @eersnington does not have access to the Railway project.

In order to get automatic PR deploys, please add @eersnington to your workspace on Railway.

@eersnington
eersnington marked this pull request as draft October 6, 2026 20:19
@eersnington

Copy link
Copy Markdown
Member Author

@gakonst hey!

I saw that you've cooked up an example of nanocodex on rivet through wasm, and i was stoked to see it!!! so I have something neat here for you too. this is nanocodex agent on rivet actors where i mapped the lifecycle of nanocodex 1:1 onto our actor lifecycle. it's not coupled to agentos, nor does it require a vm to start, and it runs lean on our actor process entirely

quick rundown of what user facing code looks like

/```ts
// actor.ts
import { nanocodex } from "@rivet-dev/nanocodex";
import { daytonaProvider } from "@rivet-dev/sandbox-adapter/daytona";
import { Transport } from "nanocodex/node";
import { setup } from "rivetkit";

export const coder = nanocodex({
	transport: () => Transport.openAi({ apiKey: process.env.OPENAI_API_KEY! }), // or chatGptSubscription((c) => ...) with a chatGptCredentials() actor as a credentials actor (we have an example of this in pi
	sandbox: daytonaProvider(), // or any from sandbox-adapter
	actions: {
		// your own actions get the agent as c.nanocodex
		review: async (c, pr: number) => {
			const turn = c.nanocodex.turn.prompt({ input: `review PR #${pr}` });
			return (await turn.result()).finalMessage;
		},
	},
});


export const registry = setup({ use: { coder } });
registry.start();
// client.ts
import { createClient } from "rivetkit/client";
import type { registry } from "./actor";

const client = createClient<typeof registry>("http://localhost:6420");
const coder = client.coder.getOrCreate(["issue-42"]);
coder.connect().on("event", (event) => console.log(event.type));

const id = crypto.randomUUID();
const input = "fix issue 42";

coder.turn.prompt({ id, input }); // starts the turn on the actor

// later, from any client, after a disconnect, a retry, or a deploy:
// the same id joins the running turn, returns the saved result, or resumes it
const { finalMessage } = await coder.turn.prompt({ id, input });

i'm not entirely familiar with nanocodex's api, so if anything looks dubious here, i'm happy to walk through more and take suggesstions =)

and, here's a list of things this nanocodex() in rivet actor brings in no particular order

  • the conversation lives in the actors sqlite
  • turns survive crashes/upgrades and picks back up from nanocodex's journal when you prompt the same turn id again
  • turn ids makes retries safe aswell
  • turns keeps running if the client disconnects
  • any sandbox through sandbox-adapter (daytona, e2b, agentos, etc etc...)
  • connect chatgpt subscriptions through chatGptCredentials() (which is another actor), and you can share the sub across actors after authing once
  • literally an extension of rivet actors so you get to mess with your state, hooks, actions pass through, and any action can access the agent with c.nanocodex
  • actions map 1:1 to nanocodex's turn.* and session.*

not entirely sure if we're gonna include this in our release pipeline as we do for pi and pi durable, but this is a fun experiment regardless.

@gakonst

gakonst commented Oct 6, 2026

Copy link
Copy Markdown

ooooooooooooooooo amazing

thank you! ill share some more thoughts in a bit. any feedback so far?

@eersnington

Copy link
Copy Markdown
Member Author

ooooooooooooooooo amazing

thank you! ill share some more thoughts in a bit. any feedback so far?

image hehe thanks! there were a few edge cases (i think) i ran into while trying this out in my locally

  • two prompts with the same turn id at the same time cancel the first one (race in nanocodex's durability layer ig?). i keep a map so the second joins. i'll send a repro for this soon (on mobile rn)
  • a shutdown that keeps unfinished turns would be neat since actors are durable. right now i use dispose() when the actor sleeps. it cant list unfinished turns, so they only resume when a client resends the id
  • chatgpt refresh only dedupes per process, so i route refreshes through one actor

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants