diff --git a/.changeset/actor-host-data.md b/.changeset/actor-host-data.md
new file mode 100644
index 0000000..02c2998
--- /dev/null
+++ b/.changeset/actor-host-data.md
@@ -0,0 +1,5 @@
+---
+"effect-machine": minor
+---
+
+Allow ActorHost factories to receive typed data from the hosting parent as a second argument. Call host(request, hostInput) to bind parent input to a generation while consumers continue to call acquire(request). Existing one-argument factories keep their current behavior.
diff --git a/AGENTS.md b/AGENTS.md
index 9bbad4f..34393a3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -229,6 +229,7 @@ Use `ActorHost.make({ identity, spawn })` when consumers must request a child wi
- Construct the host in the service layer. `spawn` captures those services.
- Run `host.host(input)` in the parent state's `.spawn` handler. It registers a generation and waits for a consumer.
- Consumers call `host.acquire(input)`. Identity values match with `Object.is`. The first matching consumer supplies the factory input.
+- For parent-owned startup data, type the second `spawn(request, hostInput)` argument and call `host(input, hostInput)`. Consumers still call `acquire(input)`. Host data belongs to that generation; pass immutable values. One-argument factories need no host data.
- Concurrent consumers share startup and its result. Cancelling one consumer does not cancel startup. ActorHost starts actors from either `Machine.spawn` or `system.spawn` before it returns them.
- The factory receives the host generation's Scope and ActorScope. State exit closes the child. Closing the host service also closes the current generation and fails pending consumers.
- A second active host fails with `ActorHostOccupiedError`. A closed generation fails pending acquisition with `ActorHostClosedError`.
diff --git a/SKILL.md b/SKILL.md
index 3d2173b..37603f6 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -370,4 +370,6 @@ Input machines require `input: (entityId) => Input`. Use `initializeState` only
Create `ActorHost.make({ identity, spawn })` in a scoped service layer. Use `host.host(input)` in a state-scoped `.spawn` handler. Use `host.acquire(input)` from consumers. Identity uses `Object.is`. The first matching consumer supplies the spawn input. Concurrent consumers share startup. Consumer cancellation does not stop startup or the actor. The parent state scope owns the actor, and host service shutdown closes any active generation. Keep session validation in the application.
+Parents can pass typed data with `host.host(input, hostInput)`. Type the second `spawn` argument to receive it. This data belongs to the host generation; consumers still call `acquire(input)`. Pass immutable values.
+
ActorHost starts direct `Machine.spawn` results too. A registered `host` wait is interrupted on generation close; consumers receive `ActorHostClosedError` before actor cleanup. A consumer attached to an old generation must acquire again after reentry. Handle expected factory errors in the parent's spawn handler. Use `Effect.orDie` only for invariant failures, because it defects the parent. Both host errors are exported from the package root.
diff --git a/docs/actors.md b/docs/actors.md
index 24f9082..542256d 100644
--- a/docs/actors.md
+++ b/docs/actors.md
@@ -78,6 +78,28 @@ const menu = yield * menuHost.acquire({ sessionId });
`host` registers one generation in the current state scope. The first matching `acquire` supplies the factory input. Identity values match with `Object.is`. Concurrent consumers share startup and its result. Consumer cancellation does not cancel startup or stop the actor. ActorHost starts the factory result, so both `Machine.spawn` and `system.spawn` work.
+When the parent owns startup data, give `spawn` a typed second argument. Pass that data to `host`; consumers still provide only the request input.
+
+```ts
+const screenHost =
+ yield *
+ ActorHost.make({
+ identity: (request: { sessionId: string }) => request.sessionId,
+ spawn: (request, screen: { destination: string }) =>
+ Machine.spawn(screenMachine, { input: { ...request, ...screen } }),
+ });
+
+parent.spawn(State.Screen, ({ state }) =>
+ screenHost
+ .host({ sessionId: state.sessionId }, { destination: state.destination })
+ .pipe(Effect.asVoid, Effect.orDie),
+);
+
+const screen = yield * screenHost.acquire({ sessionId });
+```
+
+The second argument belongs to the registered generation. A consumer cannot replace it. Reentry supplies new host data. Host data is passed by reference, so use immutable values. Existing one-argument factories need no host data.
+
The factory uses the services captured when the host was made. Its Scope and ActorScope belong to the hosting generation. State exit closes the actor. Closing the host service also closes the current generation and ends consumers waiting for a future generation.
Consumers receive `ActorHostClosedError` before actor cleanup starts. An acquisition belongs to the generation it observed. Acquire again after reentry to get the new actor. The registered `host` wait is interrupted when its generation closes. Calls made after the host service closes fail with `ActorHostClosedError`. An overlapping host fails with `ActorHostOccupiedError`, including while the previous actor is still being cleaned up.
diff --git a/src/actor-host.ts b/src/actor-host.ts
index 9c8d688..c83f404 100644
--- a/src/actor-host.ts
+++ b/src/actor-host.ts
@@ -7,10 +7,18 @@ import { ActorHostClosedError, ActorHostOccupiedError } from "./errors.js";
export { ActorHostClosedError, ActorHostOccupiedError } from "./errors.js";
-export interface ActorHost {
+export interface ActorHost<
+ Input,
+ S extends { readonly _tag: string },
+ E,
+ Output,
+ Failure,
+ HostInput = void,
+> {
/** Register in the current scope. Wait for acquisition; interrupt if this generation closes. */
readonly host: (
input: Input,
+ hostInput: HostInput,
) => Effect.Effect<
ActorRef,
Failure | ActorHostClosedError | ActorHostOccupiedError,
@@ -27,7 +35,9 @@ export interface ActorHost(options: {
readonly identity: (input: Input) => unknown;
- readonly spawn: (input: Input) => Effect.Effect, Failure, R>;
+ readonly spawn: (
+ input: Input,
+ hostInput: HostInput,
+ ) => Effect.Effect, Failure, R>;
}) {
type Actor = ActorRef;
interface Entry {
@@ -77,9 +91,9 @@ export const make = Effect.fn("effect-machine.actorHost.make")(function* <
),
);
- const host: ActorHost["host"] = Effect.fn(
+ const host: ActorHost["host"] = Effect.fn(
"effect-machine.actorHost.host",
- )((input) =>
+ )((input, hostInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (yield* Deferred.isDone(closed)) return yield* failClosed;
@@ -127,7 +141,9 @@ export const make = Effect.fn("effect-machine.actorHost.make")(function* <
entry.actor,
Deferred.await(entry.requested).pipe(
Effect.flatMap((requested) =>
- Machine.scoped(options.spawn(requested).pipe(Effect.tap((actor) => actor.start))),
+ Machine.scoped(
+ options.spawn(requested, hostInput).pipe(Effect.tap((actor) => actor.start)),
+ ),
),
Scope.provide(scope),
Effect.provideContext(services),
@@ -150,7 +166,7 @@ export const make = Effect.fn("effect-machine.actorHost.make")(function* <
}),
),
);
- const acquire: ActorHost["acquire"] = Effect.fn(
+ const acquire: ActorHost["acquire"] = Effect.fn(
"effect-machine.actorHost.acquire",
)((input) =>
Effect.raceFirst(
@@ -169,5 +185,5 @@ export const make = Effect.fn("effect-machine.actorHost.make")(function* <
Deferred.await(closed).pipe(Effect.andThen(failClosed)),
),
);
- return { host, acquire } satisfies ActorHost;
+ return { host, acquire } satisfies ActorHost;
});
diff --git a/test/actor-host.test.ts b/test/actor-host.test.ts
index 2719fd9..7b94b6b 100644
--- a/test/actor-host.test.ts
+++ b/test/actor-host.test.ts
@@ -86,6 +86,37 @@ describe("ActorHost", () => {
}).pipe(Effect.provide(ActorSystemDefault)),
);
+ it.scoped("keeps typed host data with its generation across consumers and reentry", () =>
+ Effect.gen(function* () {
+ const host = yield* ActorHost.make({
+ identity: (input: { session: object; request: string }) => input.session,
+ spawn: (input, owner: { destination: string }) =>
+ Machine.spawn(child, { input: { value: `${owner.destination}:${input.request}` } }),
+ });
+ const session = {};
+ const waiting = yield* host.acquire({ session, request: "early" }).pipe(Effect.forkScoped);
+ yield* yieldFibers;
+ const firstOwner = yield* ownerScope;
+ const firstHosted = yield* host
+ .host({ session, request: "registration" }, { destination: "rewards" })
+ .pipe(Scope.provide(firstOwner), Effect.forkScoped);
+ const first = yield* Fiber.join(waiting);
+ expect(yield* first.snapshot).toEqual(ChildState.Ready({ value: "rewards:early" }));
+ expect(yield* Fiber.join(firstHosted)).toBe(first);
+ expect(yield* host.acquire({ session, request: "later" })).toBe(first);
+ yield* Scope.close(firstOwner, Exit.void);
+ expect((yield* first.call(ChildEvent.Finish)).transitioned).toBe(false);
+ const nextOwner = yield* ownerScope;
+ const nextHosted = yield* host
+ .host({ session, request: "registration" }, { destination: "history" })
+ .pipe(Scope.provide(nextOwner), Effect.forkScoped);
+ const next = yield* host.acquire({ session, request: "next" });
+ expect(next).not.toBe(first);
+ expect(yield* Fiber.join(nextHosted)).toBe(next);
+ expect(yield* next.snapshot).toEqual(ChildState.Ready({ value: "history:next" }));
+ }),
+ );
+
it.scoped("keeps actual recovery running when an acquiring consumer cancels", () =>
Effect.gen(function* () {
const system = yield* ActorSystemService;
diff --git a/test/type-constraints.test.ts b/test/type-constraints.test.ts
index fccc850..e1f37e9 100644
--- a/test/type-constraints.test.ts
+++ b/test/type-constraints.test.ts
@@ -14,6 +14,7 @@
*/
import { Effect, Schema, Context } from "effect";
import {
+ ActorHost,
ActorSystemDefault,
ActorSystemService,
Machine,
@@ -329,3 +330,19 @@ const _test9b = PayloadReplyEvent.GetById(_test9bPayload);
const _test9bId: string = _test9b.id;
// This file should compile with all @ts-expect-error comments being valid
+
+const _hostInputTypes = Effect.gen(function* () {
+ const host = yield* ActorHost.make({
+ identity: (request: { session: string }) => request.session,
+ spawn: (_request, owner: { url: string }) =>
+ Machine.spawn(_test1, { hydrate: MyState.Loading(owner) }),
+ });
+ const _call1 = host.host({ session: "session" }, { url: "/menu" });
+ const _call2 = host.acquire({ session: "session" });
+ // @ts-expect-error - the factory requires host data
+ const _call3 = host.host({ session: "session" });
+ // @ts-expect-error - host data must match the factory's exact type
+ const _call4 = host.host({ session: "session" }, { url: 123 });
+ // @ts-expect-error - consumers cannot supply host data
+ const _call5 = host.acquire({ session: "session" }, { url: "/other" });
+});