Actors
Understand actors, allocate root and child threads, and invoke typed methods.
In Tardigrade, an actor is an addressable entity with private state and behavior. This builds on the actor model, introduced in 1973. Related implementations include Erlang/OTP and Microsoft Orleans.
A good analogy is to think of actors like regular people we interact with. People typically hold information that is accessible only to them. Others access this private information or elicit behaviors from them through communication.
Similarly, in Tardigrade, actors hold information that is private to them. This is the actor's event log. The rest of the world communicates with them and elicit behaviors via typed methods. This will become clearer as you walk through the concepts below.
Define an actor
An actor definition describes its methods and behavior. Defining an actor does not start a runtime.
interface Actor {
name: string
methods: ActorMethods
components: ReadonlyArray<Component>
}
For example, an actor named meeseeks exposes a message method and uses components for inference and code execution with an MCP package. The ./components, ./methods, ./packages, and ./layers modules in this guide belong to your application. For a runnable project with these services already assembled, start with the Quickstart.
import { defineActor } from "tardie/core"
import { infer, codeMode } from "./components"
import { message } from "./methods"
import { mcpPackage } from "./packages"
const meeseeks = defineActor(
"meeseeks",
{ message },
[infer, codeMode(mcpPackage)],
)
Create a host
A host runs the actor and owns its storage, threads, and message delivery. The Bun host can run inside a script or application.
import { defineActor } from "tardie/core"
import { createBunHost } from "tardie/bun"
import { layersFor } from "./layers"
import { infer, codeMode } from "./components"
import { message } from "./methods"
import { mcpPackage } from "./packages"
const meeseeks = defineActor(
"meeseeks",
{ message },
[infer, codeMode(mcpPackage)],
)
const host = await createBunHost({
actor: meeseeks,
storage: "./data",
layersFor,
})
Use host to allocate threads and call methods from the surrounding application. Close the host with await host.close() when the application is finished using it.
Supply component requirements
layersFor(thread, instance) returns an Effect layer supplying services your components need, such as LanguageModel for model calls. Bun supplies the log, routing, thread identity, allocation, and workspace storage. TypeScript requires layersFor only when additional services are needed.
import { Layer, Redacted } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OpenAiClient, OpenAiLanguageModel } from "@tardie/ai-openai"
export const modelLayer = (apiKey: string) =>
OpenAiLanguageModel.layer({ model: "gpt-5" }).pipe(
Layer.provide(OpenAiClient.layer({ apiKey: Redacted.make(apiKey) })),
Layer.provide(FetchHttpClient.layer),
)
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error("OPENAI_API_KEY is required")
export const layersFor = (_thread: string) => modelLayer(apiKey)
Allocate threads
The host returns references with callable methods. Repeating an allocation with the same scope and name returns a reference to the same logical thread.
Actor instances
An instance scopes the actor's threads. For example, "rick" can identify Rick's instance of meeseeks. Morty can use the same actor definition with a different instance identity.
Root threads
The caller supplies a name within the actor instance. The host assigns the thread identity and returns a reference.
const rickMain = await host.allocateRootThread({ instance: "rick", name: "main" })
const rickLab = await host.allocateRootThread({ instance: "rick", name: "lab" })
const mortyMain = await host.allocateRootThread({ instance: "morty", name: "main" })
const mortyLab = await host.allocateRootThread({ instance: "morty", name: "lab" })
Rick and Morty each have two threads, "main" and "lab". They can reuse these names because their instances provide separate scopes.
Child threads
A thread can create another logical thread in the same actor instance. For example, Rick's "main" thread can create a "researcher" thread. It lives alongside "main" and "lab" as another thread in Rick's instance. The parent-child relationship determines its naming scope.
const researcher = await host.allocateChildThread({
parent: rickMain.coordinate,
name: "researcher",
})
const factChecker = await host.allocateChildThread({
parent: researcher.coordinate,
name: "fact-checker",
})
researcher.coordinate contains the actor, instance, and thread names:
{ "actor": "meeseeks", "instance": "rick", "thread": "researcher" }
The supervisor log records each thread's parent and depth, so the host can reconstruct the hierarchy while each coordinate stays actor/instance/thread.
Generated names
Omit name to let the host assign a name. By default, generated names are friendly slugs such as quiet-fox-k7m2, made from an adjective, a noun, and a short random token. The host can change the word lists and token length or supply its own name generator. It checks for collisions, retries if a name is already taken, and stores the assignment for reuse.
const root = await host.allocateRootThread({ instance: "rick" })
const child = await host.allocateChildThread({ parent: root.coordinate })
Invoke a thread
Once a thread is allocated, a caller can use its reference to invoke a method defined by the actor. For example, rickMain identifies Rick's "main" thread, where a caller can invoke meeseeks's message method.
Object.keys(rickMain.methods) // ["message"]
const response = await rickMain.methods.message({
text: "Help me build a portal gun.",
}, { key: "portal-plan" })
Calls through the host use ordinary await calls. The key identifies the invocation so a retry can reuse the same call and its result.
Serve over HTTP
Expose the host to other processes with an HTTP server. The host continues to own execution and storage; the server provides network access.
import { serve } from "tardie/bun"
const server = await serve(host, { port: 4242 })
Call Rick's main thread from another process:
curl -X POST 'http://localhost:4242/v1/actors/rick/threads/main/methods/message' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: portal-code' \
-d '{"text":"What is the portal code?"}'
The Idempotency-Key header makes retries reuse the same call. The server returns 202 Accepted with a receipt and a Location header. Poll that location with GET until the state is completed, failed, or cancelled; the first read may still be pending.
Remote applications connect through the Client SDK. A local script can use host directly. Stop the HTTP server with await server.close() before closing its host.
Complete example
This script defines an actor, creates its host, invokes two threads, and closes the host when the work finishes. The imported components, methods, and packages belong to your application.
import { defineActor } from "tardie/core"
import { createBunHost } from "tardie/bun"
import { layersFor } from "./layers"
import { infer, codeMode } from "./components"
import { message } from "./methods"
import { mcpPackage } from "./packages"
const meeseeks = defineActor(
"meeseeks",
{ message },
[infer, codeMode(mcpPackage)],
)
const host = await createBunHost({
actor: meeseeks,
storage: "./data",
layersFor,
})
try {
const rickMain = await host.allocateRootThread({ instance: "rick", name: "main" })
const rickLab = await host.allocateRootThread({ instance: "rick", name: "lab" })
const mortyMain = await host.allocateRootThread({ instance: "morty", name: "main" })
const mortyLab = await host.allocateRootThread({ instance: "morty", name: "lab" })
const rickResponse = await rickLab.methods.message({
text: "Design an experiment to test the portal gun's power source.",
}, { key: "portal-experiment" })
const mortyResponse = await mortyMain.methods.message({
text: "Help me plan my day around school and homework.",
}, { key: "school-plan" })
console.log({ rickResponse, mortyResponse })
} finally {
await host.close()
}
How threads are stored
Supervisor log
The host keeps an event log for each actor instance, called the supervisor log. It records which threads belong to the instance and how they relate. Each thread also has its own log for that thread's events.
For Rick's instance, allocation records in the supervisor log can look like this. Allocation keys and timestamps are omitted:
| Event | Thread | Parent thread | Depth |
|---|---|---|---|
ThreadRequested | main | None | 0 |
ThreadRequested | lab | None | 0 |
ThreadRequested | researcher | main | 1 |
ThreadRequested | fact-checker | researcher | 2 |
main and lab are root threads at depth 0. researcher is a child of main, and fact-checker is a child of researcher. The host builds the instance's thread directory from these records.
Physical placement
A reference identifies a thread. The host resolves it to where that thread runs, so callers do not need to know its location. The receiving host is also responsible for access checks.
For Rick's main thread, the Cloudflare host uses JSON arrays to name the actor instance and thread Durable Objects:
const actor = "meeseeks"
const instance = "rick"
const thread = "main"
const supervisor = env.ACTORS.getByName(JSON.stringify([actor, instance]))
const target = env.THREADS.getByName(JSON.stringify([actor, instance, thread]))
Durable Object namespaces
├── ACTORS
│ └── ["meeseeks","rick"] # Rick's instance
└── THREADS
├── ["meeseeks","rick","main"] # main
└── ["meeseeks","rick","lab"] # lab
The Bun host encodes the JSON pair [actor, instance] as the instance database filename. It stores each thread beside that database, using the thread ID as a base64url filename:
import { Buffer } from "node:buffer"
import { join } from "node:path"
const instanceFile = Buffer.from(JSON.stringify(["meeseeks", "rick"])).toString("base64url")
const actorDatabase = join("/data", `${instanceFile}.sqlite`)
const thread = "main"
const filename = Buffer.from(thread, "utf8").toString("base64url")
const target = join(`${actorDatabase}.threads`, `${filename}.sqlite`)
/data/
├── WyJtZWVzZWVrcyIsInJpY2siXQ.sqlite # Rick's instance database
└── WyJtZWVzZWVrcyIsInJpY2siXQ.sqlite.threads/
├── bWFpbg.sqlite # main
└── bGFi.sqlite # lab
Next steps
Create a starter project with the CLI, then follow the Quickstart to run it:
tdg init tardie-agent --template quickstart
In a future guide, we'll be covering more about actor methods.