Reflection

Every spawned process exposes a $reflection handle — callable methods that let you inspect and drive a live actor from outside its message loop.

Defining methods

Add methods with the $reflectionMethods config field. They appear on the process handle as proc.$reflection:

import { defineActor } from "posipaki";

const counter = defineActor({
  $reflectionMethods: {
    async getCount() {
      return this.state.count;
    },
    async reset() {
      this.state.count = 0;
    },
  },
  setup: () => ({ count: 42 }),
  handlers: {},
});

const proc = await counter.spawn(null);
await proc.ready();

await proc.$reflection.getCount(); // 42
await proc.$reflection.reset();
await proc.$reflection.getCount(); // 0

Every call is async

A reflection method is written as one that answers with a promise, and a call always gives you one — locally it is the method's value wrapped, over a wire it is the frame that has to arrive first. A caller reads the same call the same way either way, so proc.$reflection.x() is awaited whether the actor is here or somewhere else.

That is a type contract, not a convention: ReflectionMethod answers with a promise and ReflectionOptions holds every declared method to it, so a method written as () => number does not compile. Write it async.

this is the actor context

Inside a method, this is the full ActorContext — this.state, this.name, this.id, this.$child, this.emit, this.exit, plus any decoration a plugin added.

$reflectionMethods: {
  async describe() {
    return { name: this.name, children: Object.keys(this.$child) };
  },
},

Typing

Your own $reflectionMethods are typed automatically from the config. proc.$reflection is typed as your methods and every method a plugin adds. Plugin-provided methods are typed via declaration merging on ActorReflection; see the Plugins page for that pattern.

declare module "posipaki" {
  interface ActorReflection {
    "counters.get": () => Promise<number>;
  }
}

Plugins add methods too

mergeConfigs deep-merges $reflectionMethods, so several plugins can each register their own namespaced methods. See Plugins.

import { mergeConfigs, type ActorPlugin } from "posipaki";

const counters: ActorPlugin = (config) =>
  mergeConfigs(config, {
    $reflectionMethods: {
      "counters.get": async function () {
        return this.state.count;
      },
    },
  });

Reaching children

Every process handle has $reflection — including children. The handle returned by fork is typed, so a child's methods are directly callable:

async setup() {
  const worker = await this.fork(workerActor);
  return { worker };
},
// ...
await this.state.worker.$reflection.getCount();

this.$child and this.ctx.children hold AnyProcess, whose $reflection is typed as {} — cast to reach a specific method:

const child = this.$child["main:worker"];
await (child.$reflection as { getCount: () => Promise<number> }).getCount();

The Introspection plugin is the bundled consumer — it walks $reflection on children to build a live process tree.

Over a wire

A remoteClient proxy answers to the same $reflection, with two differences worth knowing.

What can be called is what the far side announced. When the proxy starts, the far side sends the list of methods it can answer; the proxy installs one function per name. A method the far side does not have is not a function on the proxy at all, rather than a call that fails later. And because the answer travels, every call there takes at least a round trip.

const tools = remoteClient<ToolArgs, ToolState, ToolIn, ToolOut>("tools", spawner);
const env = await root.fork(tools, { env: "agent" });

await env.$reflection["inspect.getTree"](); // the far side's own tree, one frame away

A process that comes back over a wire is a handle, not the process. A method may return a process — inspect.find does. Locally you get the process itself; over a wire you get a RemoteProcess: the name the far side knows it by, the id this connection uses for it, and the ways to talk to it.

const found = await env.$reflection["inspect.find"]("env:worker");
found.pname;                             // "env:worker"
found.ref.id;                            // this connection's id for it
found.send({ type: "PING" });             // a message for it
found.$reflection["inspect.getTree"]();   // what it announced it can answer

A reference handed the other way — as an argument to another call — travels as the same id, and comes back to the side that holds the process as the process itself. See Remote actors for what a handle is and is not, and for how much of it you hear — nothing crosses until you ask.