@golemcloud/effect-golem
v2.0.3
Published
Write Golem agents on top of Effect v4: Effect Schema-typed methods, durable execution, snapshots, sagas, RPC, HTTP, webhooks, KV/blob, and RDBMS adapters compiled to a Golem WASM component.
Readme
@golemcloud/effect-golem
Effect-native TypeScript SDK for Golem 1.6 agents. It uses Effect 4 schemas and effects while targeting Golem's WASI Preview 3 agent, tool, and tool-middleware contracts.
Keep the
effectversion generated for an application aligned with the SDK.
Install and define an agent
npm install @golemcloud/effect-golem effectThe 1.6 vocabulary is id for agent identity, input for method input, and the client attached to
the definition. defineAgent creates an inert specification; .implement({ init, methods })
registers it.
import { Effect, Ref, Schema } from "effect"
import { defineAgent, method } from "@golemcloud/effect-golem"
export const Counter = defineAgent({
name: "Counter",
mode: "durable",
id: { name: Schema.String },
methods: {
value: method({ input: {}, success: Schema.Number, readOnly: true }),
add: method({ input: { by: Schema.Number }, success: Schema.Number }),
},
})
Counter.implement({
init: () => Ref.make(0),
methods: (count) => ({
value: () => Ref.get(count),
add: ({ by }) => Ref.updateAndGet(count, (value) => value + by),
}),
})method optionally accepts error, description, promptHint, HTTP endpoints, and readOnly.
Read-only metadata may be true or { cache }; principal-aware caching is derived from a declared
PrincipalSchema input. Public modules are namespaces from
the root (Agent, Client, Config, Durability, Snapshot, Tool, etc.); only
defineAgent, defineAgentClient, defineConfig, and method are flat DSL aliases. Database adapters and standalone
middleware use the documented sub-imports. internal/* and host/* are not public imports.
Typed clients
Keep a definition in a module without calling .implement when another component only needs its
client. Durable definitions expose client.get, getPhantom, and newPhantom; ephemeral
definitions expose getPhantom and newPhantom, but not ordinary get. Calls, triggers, and
schedules take one typed input object.
const program = Effect.scoped(
Effect.gen(function* () {
const counter = yield* Counter.client.get({ name: "orders" })
yield* counter.add({ by: 2 })
yield* counter.add.trigger({ by: 3 })
const scheduled = yield* counter.add.schedule("10 minutes", { by: 1 })
// yield* scheduled.cancel()
}),
)Awaited RPC is interruptible: fiber interruption cancels the result handle. Cancellation is best
effort; remote side effects may already have happened. Triggering and scheduling methods whose
schema contains a live stream is rejected because streams require an awaited invocation. An
ephemeral call returns { metadata, value }, and an ephemeral trigger/schedule exposes invocation
metadata. Config overrides are passed as the second argument to client.get/newPhantom.
Use defineAgentClient for caller-owned clients. A full client definition has a declared name,
constructor schema, lifecycle mode, and methods; it exposes agentId and client factories without
registering an implementation. A method-only { methods } definition has neither factory nor
identity constructor; it binds without discovery and assumes durable result semantics. An
unimplemented defineAgent spec remains usable as a shared implementation/caller definition.
identity.client(clientDefinition) validates the declared name and constructor schema before opening RPC.
Client.bind(identity, clientDefinition) and DynamicClient.bind(identity) remain lower-level functions.
import { Effect, Schema } from "effect"
import { AgentIdentity, defineAgentClient, method } from "@golemcloud/effect-golem"
import type * as CoreTypes from "golem:core/[email protected]"
const Target = defineAgentClient({
name: "Target",
id: { name: Schema.String },
methods: { echo: method({ input: { message: Schema.String }, success: Schema.String }) },
})
const calls = (inputTree: CoreTypes.SchemaValueTree) =>
Effect.scoped(
Effect.gen(function* () {
const identity = yield* Target.agentId({ name: "main" })
const full = yield* identity.client(Target)
const first = yield* full.echo({ message: "full" })
const methods = defineAgentClient({ methods: Target.methods })
const second = yield* (yield* identity.client(methods)).echo({ message: "method only" })
const parsed = yield* AgentIdentity.parse(identity.encoded)
const dynamic = yield* parsed.dynamicClient()
const raw = yield* dynamic.method("echo").invoke(inputTree)
// Dynamic methods accept native SchemaValueTree inputs and return metadata plus native values.
return { first, second, raw }
}),
)Complete ephemeral specs require a phantom ID for agentId(input, phantomId) and reject generic
existing-ID binding. Address known or fresh phantoms through their factories instead.
For runtime-selected targets, use Reflection.getAgentType(name),
getAgentTypeByAgentId(parsedIdentity), or getAllAgentTypes. Each immutable registration exposes
constructor/method SchemaRef values with JSON/value validation and JSON Schema rendering.
Narrow type.mode to select its lifecycle factory:
import { Effect } from "effect"
import { Reflection } from "@golemcloud/effect-golem"
const invokeCounter = Effect.scoped(
Effect.gen(function* () {
const type = yield* Reflection.getAgentType("Counter")
if (type === undefined || type.mode !== "durable") return undefined
const client = yield* type.client.get({ name: "main" })
const add = yield* client.method("add")
return yield* add.invoke({ by: 1 })
}),
)Reflected invocation results contain metadata and, for non-unit outputs, value.
Ephemeral newPhantom returns a client whose actual identity arrives in invocation metadata;
getPhantom(input, phantomId) addresses a known phantom without offering ordinary existing-ID
binding. Durable newPhantom returns { client, agentId, phantomId }, with a parsed agentId.
Durable reflected types also bind through Client.bind(identity, reflectedType) after name and
constructor validation. Discovery misses remain undefined; host and malformed-schema failures
enter the typed error channel. invokeValue, triggerValue,
and scheduleValue accept native WIT schema-value trees when JSON cannot represent capabilities.
parsedIdentity.dynamicClient() binds without discovery and uses value-only methods; callers
must supply the correct remote method definitions. Both APIs use scopes and fiber interruption and expose
the same structured remote-call errors as typed clients.
The four client approaches are: Normal RPC through the ordinary client for a shared source definition; method-only or full caller-defined static clients; discovered clients backed by an immutable deployed schema snapshot; and fully dynamic schema-native clients for existing durable identities. Method-only clients do not own identity, lifecycle, mode, or config declarations. Full clients own those declarations and the matching durable, phantom, or ephemeral factories. Dynamic clients neither discover nor create.
Normal RPC and caller-defined static clients encode through their local schemas before opening RPC;
full binding also checks the declared name and constructor shape. Discovered clients apply all
snapshot restrictions and validate
declared result cardinality and shape. The host remains authoritative for visibility,
authorization, effective configuration, durable identity resolution, and deployed input schemas.
Typed inputs use Schema.optional(...) normally. Canonical reflected JSON records contain every
field and use null for an absent option. Wide integers are decimal strings: duration is
{ nanoseconds: "..." } and quantity uses a decimal-string mantissa; integers through 32 bits
remain numbers. Streams and opaque capabilities require *Value APIs and transfer ownership once.
Unstructured outputs and HTTP responses
Unstructured.UnstructuredText() and Unstructured.UnstructuredBinary() declare unstructured
values. A method returns them as TextReferenceValue or BinaryReferenceValue: either
{ _tag: "inline", val, ... } with the data, or { _tag: "url", val }. Wrap an element in
Unstructured.Option(...) to return an Option of the reference. Unstructured.Option is only a
method success; it is not an input parameter or a multimodal member. Each form combines with a
typed error:
import { Effect, Option, Schema } from "effect"
import { defineAgent, Http, method, Method, Unstructured } from "@golemcloud/effect-golem"
const Pending = Schema.Struct({ _tag: Schema.Literal("Pending") })
export const Files = defineAgent({
name: "Files",
id: { bucket: Schema.String },
http: Http.mount("/files/{bucket}"),
methods: {
download: method({
input: { id: Schema.String },
success: Unstructured.Option(
Unstructured.UnstructuredBinary({ restrictions: [{ mimeType: "image/png" }] }),
),
error: Pending,
}).pipe(Method.withHttp(Http.get("/{id}"))),
},
})
Files.implement({
init: () => Effect.void,
methods: () => ({
download: ({ id }) =>
id === "missing"
? Effect.succeed(Option.none())
: id === "pending"
? Effect.fail({ _tag: "Pending" as const })
: Effect.succeed(
Option.some({ _tag: "inline", val: new Uint8Array(), mimeType: "image/png" }),
),
}),
})The output schema is the element's variant { inline, url } with an unstructured-text or
unstructured-binary role. Unstructured.Option wraps it in option<_>, and a typed error wraps
the success in result<_, error>. The role stays on the nested variant. Typed clients decode the
same reference and Option values and return the typed error in the Effect error channel.
Reflection and DynamicClient see the schema value with the role intact.
The Golem HTTP host maps the output of an HTTP endpoint method as follows:
| Output | Response |
| ---------------------------- | --------------------------------------------------------------- |
| element | inline → 200 with the raw body; url → redirect |
| option<element> | none → 404; some → as the element |
| result<element, E> | ok → as the element; err → 500 with the error as JSON |
| result<option<element>, E> | ok(none) → 404; ok(some) → as the element; err → 500 JSON |
Other success schemas use the same option and result rules with a JSON body in place of the raw
body.
Configuration and opaque secrets
Schema.Redacted(inner) declares a secret. The config wire value is an opaque host handle;
config.apiKey.get asks the secrets host to reveal and decode it only at read time, then returns an
Effect containing Redacted<inner>. Do not log, stringify, cache, snapshot, or put secret values in
RPC overrides.
import { Effect, Redacted, Schema } from "effect"
import { defineAgent, defineConfig, method } from "@golemcloud/effect-golem"
class ServiceConfig extends defineConfig("Service.Config", {
endpoint: Schema.String,
apiKey: Schema.Redacted(Schema.String),
tuning: Schema.Struct({ retries: Schema.Number }),
}) {}
const Service = defineAgent({
name: "Service",
id: { tenant: Schema.String },
config: ServiceConfig,
methods: { endpoint: method({ input: {}, success: Schema.String }) },
})
Service.implement({
init: () =>
Effect.gen(function* () {
const config = yield* ServiceConfig
const endpoint = yield* config.endpoint
const apiKey = yield* config.apiKey.get
// Reveal only at the boundary that needs plaintext:
void Redacted.value(apiKey)
return endpoint
}),
methods: (endpoint) => ({ endpoint: () => Effect.succeed(endpoint) }),
})Plain leaves are invocation-local cached; secret reads are not cached. Schema.optional(...)
declares optional values. Nested structs map to path segments. RPC overrides include only non-secret
leaves:
yield *
Service.client.get(
{ tenant: "acme" },
{ overrides: { endpoint: "https://example.test", tuning: { retries: 2 } } },
)Config is never stored in snapshots. Restoration reads current host values.
Snapshots and restoration
Snapshotting agents initialize runtime state with init(id). The implementation's snapshot
strategy restores that state before methods(state) constructs handlers. Custom restoration
receives a SnapshotRestorationContext containing id, principal, phantomId, agentId,
parsedAgentId, and current config; it must not perform fresh-instance initialization effects.
import { Effect, Ref, Schema } from "effect"
import { defineAgent, method, Snapshot } from "@golemcloud/effect-golem"
const definition = defineAgent({
name: "SnapshotCounter",
id: { name: Schema.String },
snapshotting: Snapshot.define({
schema: Schema.Struct({ count: Schema.Number }),
policy: Snapshot.policy.everyN(10),
}),
methods: { value: method({ input: {}, success: Schema.Number }) },
})
const handlers = (state: Ref.Ref<{ readonly count: number }>) => ({
value: () => Ref.get(state).pipe(Effect.map((value) => value.count)),
})
definition.implement({
init: () => Ref.make({ count: 0 }),
methods: handlers,
snapshot: Snapshot.ref<{ count: number }>(),
})For SQLite snapshots, add databases: state => ({ main: state.database }) to the snapshot strategy;
the SDK hydrates in-memory/temp images before constructing methods. DDL must be idempotent. A
Snapshot.custom(...) implementation uses { save(state), restore(saved, context) } with raw
bytes. Snapshot schema evolution remains the application's responsibility.
Auto snapshots can declare databases: ["main"] as const; expose the corresponding SDK
SqliteClient or node:sqlite DatabaseSync from the implementation snapshot strategy. Every
declared database must be present, be in autocommit mode, and have no extra attached schemas.
External Postgres/MySQL/Ignite data is not part of a worker snapshot.
For schema state plus arbitrary named bytes, opt into Snapshot.multipart({ schema, policy,
databases? }) and provide an explicit strategy. save(ref) returns an Effect of
{ state, parts: new Map([["index", { bytes, contentType: "application/octet-stream" }]]) };
restore(saved, context) receives decoded state and the complete user-part Map.
yield* Snapshot.requirePart(saved.parts, "index", "application/octet-stream") checks both presence
and MIME type. Binary-only applications use Schema.Null state. Logical names are case-sensitive
[A-Za-z0-9_][A-Za-z0-9_.-]*; content types are bare ASCII MIME types without parameters.
The SDK owns the envelope and principal, always emits multipart (including zero parts), and never
interprets user parts as resources. Existing JSON and byte-only modes are unchanged.
Optional managed SQLite composition uses the same databases accessor in both Snapshot.define
and Snapshot.multipart. DatabaseSync.location() classifies each handle: null means an
in-memory/temp image; a non-null location is recorded in fileDatabases metadata only. Host
filesystem snapshots exclusively own file-backed contents. The SDK never captures those contents
as application parts or hydrates them from bytes, even when filesystem snapshotting is disabled.
Filesystem snapshot availability is controlled by the host; without it, changed files prevent
periodic snapshots and snapshot-based manual updates.
The SDK validates the complete, disjoint image/location inventory and checks recorded files exist
before restore. That factory must reopen the recorded locations. All returned handles must match
their recorded categories and locations. Memory/temp images hydrate and connection caches warm
before methods; defer database-dependent reconstruction until then. Small nonempty file databases
may be read in full during cache warming, with the bytes discarded rather than stored in the
application snapshot. Use scoped database acquisition for cleanup if restoration fails. Raw
handles and SqliteClient.fromDatabase wrappers remain externally owned unless you register a
finalizer or request closeOnScopeClose: true. Arbitrary user parts remain application-owned.
Snapshot hooks run unpersisted and may be retried. Whole-buffer
copies amplify peak memory use; the host's 64 KiB inline/blob-spill threshold is not a size cap.
See the multipart examples for all SDKs.
Agent streams
Use WitTypes.AgentStream(itemSchema) inside any input or output schema and pass native Effect
Stream<T> values. Local streams are reusable. Streams received from Preview 3 endpoints are
demand-driven, single-reader, and affine: consuming or forwarding one transfers ownership, so a
received stream must not be reused.
Early break, return(), or Effect interruption closes the readable endpoint and awaits local
cleanup. A downstream drop stops future source pulls and eventually calls the producer iterator's
return() once, but P3 cannot interrupt an arbitrary pending next() or promise cleanup before a
later invocation. Terminal errors are not transported; model recoverable failures as stream items
(for example Result<T, E>). Streams cannot be snapshotted, triggered, or scheduled.
Effect HTTP routers
HttpRouter.define registers a parameterless ephemeral HTTP router, without snapshots or an
ordinary agent client. Supply a real Effect application factory; it runs inside each request's
scope. Normal typed agent clients remain available inside handlers:
import { Effect, Layer } from "effect"
import { HttpRouter as Routes, HttpServerRequest, HttpServerResponse } from "effect/http"
import { Http, HttpRouter } from "@golemcloud/effect-golem"
const routes = Layer.mergeAll(
Routes.add(
"GET",
"/count",
Effect.gen(function* () {
const counter = yield* Counter.client.get({ name: "web" })
return HttpServerResponse.text(String(yield* counter.value({})))
}),
),
Routes.add(
"POST",
"/echo",
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return HttpServerResponse.stream(request.stream)
}),
),
)
HttpRouter.define("Web", {
mount: Http.mount("/web", {
fileResponseHeaders: { "content-security-policy": "default-src 'self'" },
}),
static: [{ route: "/assets/*", path: "/public/$1" }],
}).implement(Routes.toHttpEffect(routes))fileResponseHeaders applies the supplied headers whenever the host serves live-agent exposed
files or router static files.
The framework sees mount-relative URLs. yield* HttpRouter.request exposes the original scheme,
authority, path, optional query, ordered byte headers, and the same single-consumer body. An absent
query is undefined; a present empty query is "". Explicit text, json, and arrayBuffer
accessors buffer with Effect's HttpIncomingMessage.MaxBodySize; ordinary stream handlers do not.
Normalized Effect headers cannot represent every original occurrence. For repeated response
fields or opaque bytes, apply HttpRouter.withRawHeaders(response, headers) after response
combinators. Those overrides survive pre-response hooks. Effect cookies become separate
set-cookie occurrences.
For an Effect HttpApi, use HttpApiBuilder.layer and HttpRouter.toHttpEffect for the application,
and a lazy openApi: Effect.sync(() => OpenApi.fromApi(Api)) provider on the same definition.
Provide the application's normal Effect layers, including the HttpPlatform, FileSystem,
Path, and Etag requirements of HttpApiBuilder; static file mappings themselves are served
by Golem, not those layers. OpenAPI paths stay mount-relative. The shared TypeScript serializer
preserves the generated document; the host validates its semantics and handles mount composition.
The optional handlerMethod and openapiProviderMethod change ordinary metadata method names.
Use .register() instead of .implement(...) for static-only or provider-only routers.
Static mappings use { route, path } objects. Files must also be included in the component's
deployment. For a regular durable non-phantom agent, use
Http.mount("/files/{id}", { exposeFiles: [{ route: "/*", path: "/data/$1" }] }) to expose its
filesystem. Mapping parsing and metadata compilation are shared with the TypeScript SDK.
Request resources stay scoped through response consumption. Endpoints decoded during a request
are adopted by that request even when their streams are transformed. HEAD and 204/205/304 never
start body programs; they release already-acquired endpoints and request resources. Put eager
application resources in Effect.acquireRelease/Effect.addFinalizer, not solely in an unopened
stream's ensuring. Scope finalizers receive the response's success, failure, or interruption.
Effect Respondable failures retain their HTTP responses, including HttpApi validation failures.
Other application failures remain invocation failures; router misses and request parsing failures become
404 and 400 responses. A late body failure must not be interpreted as successful invocation EOF.
Raw response bodies other than Uint8Array, response FormData, and WebSocket upgrades are not
supported by this adapter.
Runtime limitation: local Effect interruption joins finalizers, but current host epoch interruption does not deliver cooperative cancellation to JavaScript. The P3 output pump also cannot interrupt a pending first pull. These paths require runtime work and deployed acceptance tests; unit tests and artifact checks do not establish HTTP lifecycle conformance.
External Durable Streams
DurableStreams.readJson(schema, options) and readBytes(options) return lazy native Effect
streams. They read finite host batches, starting with catch-up and following with long-poll
(default) or SSE. Offsets and cursors are opaque. offset: "now" is resolved once per stream
execution; an empty live batch is not EOF, and a closed batch emits its payload before EOF.
Each execution of a local stream starts at its configured offset. Native agent-stream forwarding
works without an iterator or Promise adapter.
Each Stream execution acquires one journaled reader resource and releases it at EOF, failure, interruption, or downstream termination. Writers acquire one journaled resource in the caller's Effect scope; keep that scope open through appends and retries. Constructors record immutable connection descriptors and the borrowed secret's pinned identity without making HTTP requests or revealing plaintext. Reads carry only checkpoint/transport/content-type; appends carry only payload/sequence/close. Repeated attempts reuse the same resource.
Scope interruption returns promptly. If a finite WIT attempt is still in flight, actual resource
disposal waits for its Promise to settle, because the method still borrows the native handle.
No new operations may start after scope exit; interrupting an append within an open writer scope
still permits retryPending on the same resource.
Before passing a host-dependent stream to an agent method (or returning it through an agent-stream schema), capture its services within the invocation:
const forward = Effect.gen(function* () {
const source = DurableStreams.readBytes({ url })
const context = yield* Effect.context<Stream.Services<typeof source>>()
return yield* sink.collect({ bytes: source.pipe(Stream.provideContext(context)) })
})import { Effect, Schema, Stream } from "effect"
import { DurableStreams, defineConfig } from "@golemcloud/effect-golem"
class StreamConfig extends defineConfig("Stream.Config", {
bearer: Schema.Redacted(Schema.String),
}) {}
const roundtrip = Effect.gen(function* () {
const cfg = yield* StreamConfig
const auth = yield* cfg.bearer.borrow // opaque handle; does not reveal the token
const options = { url: "https://streams.example/events", auth }
const writer = yield* DurableStreams.makeJsonWriter(Schema.String, options)
yield* writer.append(["first", "last"], { close: true })
return yield* DurableStreams.readJson(Schema.String, options).pipe(Stream.runCollect)
}).pipe(Effect.scoped)Writers serialize concurrent effects and retain one immutable producer ID/epoch/sequence, body,
and close flag after a failed or interrupted append. New data is rejected while hasPending is
true: explicitly run writer.retryPending to resolve the same request. writer.close consumes
a sequence too. The pending operation also retains its automatic retry budget and interrupted
backoff; an explicit retry does not reset either. A receipt's nextOffset may be absent on a duplicate acknowledgement; there is
no duplicate boolean. Finite host attempts have bounded retries (maxRetries, retryDelayMs,
timeoutMs) and honor Retry-After. Fiber cancellation stops waiting but does not prove the remote
append was cancelled; its retained request must still be resolved.
JSON uses the SDK's schema codecs and canonical JSON representation. Arrays inside a message
stay arrays. The default codec rejects unsafe integers rather than rounding them. For exact
unquoted 64-bit integers, use WitTypes.Uint64 with deterministic decode: BigInt / encode: String
callbacks; callbacks receive one complete message and schema validation still applies.
Producer identity generation uses the runtime's durable randomness. Replay must reproduce the same construction and operation order; a fork retains its producer identity and must not be treated as an independent producer. These stateful writer objects and live readers are not snapshot values. Unit-test host fakes validate SDK state transitions, not executor crash recovery.
Generated Effect bridges
Effect components receive Effect-native guest clients for their manifest dependencies.agents
and dependencies.tools during golem build. These clients preserve the target's schema without
requiring a duplicate defineAgent or tool definition. Generated methods take positional arguments,
unlike definition-derived clients, which take an input object.
For example, a generated guest client for a peer with a nested streaming method is used inside an agent handler as follows:
import { Effect, Stream } from "effect"
import { TsPeer } from "ts-peer-guest-client"
const program = Effect.scoped(
Effect.gen(function* () {
const peer = yield* TsPeer.get("peer-1")
const input = Stream.make({ id: 1, values: [9, 12] })
const output = yield* peer.nestedStream("fx", input)
return yield* output.items.pipe(Stream.runCollect)
}),
)Guest bridges use the same affine received streams and capability handles as the SDK. Their host
service requirements flow through the Effect environment; the agent dispatcher supplies live
services. Stream-free methods also expose .trigger(...) and .schedule(...), with cancelable
scheduling. Streaming methods cannot be triggered or scheduled.
Generated tool clients expose independent Effect stdin/stdout/stderr streams and an Effect result. Consume both declared outputs and the result concurrently when the tool can block writing output:
import { Effect, Stream } from "effect"
import { TsCrossStreamingClient } from "ts-cross-streaming-tool-guest-client"
const program = Effect.scoped(
Effect.gen(function* () {
const invocation = yield* TsCrossStreamingClient.create().ts_cross_streaming(
"label",
Stream.make(new TextEncoder().encode("hello")),
)
return yield* Effect.all(
[invocation.result, invocation.stdout.pipe(Stream.decodeText(), Stream.runCollect)],
{ concurrency: "unbounded" },
)
}),
)Scope closure detaches result observation and releases owned output streams without cancelling the
invocation. Run invocation.cancel explicitly to cancel accepted work. Tool failures retain their
declared payloads separately from RPC failures, and decoding validates the exact schema graph.
For an external Effect application, request an external bridge in the manifest and run
golem generate-bridge --language effect:
bridge:
effect:
external:
agents: my-app:producer
outputDir: bridge/effectEach generated <agent>-client directory is a buildable npm package. Its configure function uses
the same configuration as the TypeScript bridge. Constructors and calls return Effects, streaming
values are Effect Streams (including nested streams), and streaming calls require a scope.
Fiber interruption aborts pending calls; successful streaming calls keep their transport alive
until scope closure. External stream-free trigger/schedule methods use generated suffixes such as
echotrigger(...) and echoschedule(...). External bridges use the canonical TypeScript HTTP and
WebSocket transport and do not depend on guest WIT modules. As with the TypeScript generator,
external bridges support agents; tool bridges are guest-only.
Tools and permission-card transfer
Tool.toolDefinition(name) builds nested, typed commands. .implement(...) registers a tool guest;
Tool.client(definition) derives a camel-cased Effect client. Bodies define positional/named input,
declared errors, a structured return, and optional stdin/stdout/stderr byte streams.
import { Effect, Schema } from "effect"
import { Tool } from "@golemcloud/effect-golem"
const Echo = Tool.toolDefinition("echo").body((body) =>
body.positional("message", Schema.String).returns(Schema.String),
)
Echo.implement({
echo: ({ message }) => Effect.succeed(message),
})
const echo = Tool.client(Echo)
const result = yield * echo({ message: "hello" })Use Tool.err(name, value) for a declared tool error and Tool.ok(value) where an explicit success
carrier is needed. After success or a declared error, implicit output completion attempts both
writers independently; a failed finish or release never replaces that structured outcome. Explicit
unhandled output failures and cancellation still fail the invocation. Handler exceptions and defects
trap; malformed returned results and undeclared returned errors report invalid-result.
For a tool selected at runtime, Reflection.getToolType(name) and getAllToolTypes discover
caller-visible registrations. The selected command exposes its canonical path, aliases, ordered
arguments, input schema, result schema, and child commands. Namespace-only nodes remain visible
but have no callable body.
import { Effect } from "effect"
import { Reflection } from "@golemcloud/effect-golem"
const call = Effect.gen(function* () {
const tool = yield* Reflection.getToolType("echo")
if (tool) {
const command = tool.client.command([])
return yield* command.invokeJson({ message: "hello" })
}
})invokeJson and invokeValue validate inputs before opening RPC and check declared outputs.
Canonical JSON records include every argument key; use null for absent optional values.
startJson and startValue expose scoped stdout, stderr, result, concurrent collection, cancellation,
and detach. Generated streaming clients expose the same operations. The result is single-use:
concurrent or repeated observation fails without opening another host get. detach stops result
delivery and releases unclaimed capabilities, including a result arriving after detach. Physical
observer disposal waits for an active host get to settle; cancel after physical disposal is a no-op.
Neither detach nor closing one output cancels the invocation or its sibling output. Closing an
output is permanent; there is no resumable pending-read guarantee. Whole-call interruption of typed
clients, reflected invoke*, or generated .collect still cancels the invocation.
Use started calls when either output is required and drain both outputs concurrently.
Reflection.DynamicToolClient accepts a
caller-packed value when the deployed schema is unavailable and does not infer validation rules.
Reflected failures are typed Effect errors, including ToolReflectionError for malformed output.
WitTypes.PermissionCard (permission-card { polymorphic: false }) represents a concrete
permission card, whose grants name concrete owners and resources.
WitTypes.PolymorphicPermissionCard (polymorphic: true) represents a template card, whose grants
may contain slot variables (?agent, ?env, ...) that resolve for each holder. A schema encodes
only cards of its own kind, and a decoded card takes the type that the parameter or result
declares. The host refuses an agent call input or output whose card does not match the declared
kind. Cards are opaque affine
capabilities: successful encoding transfers the exact handle across agent RPC, tool calls, and
middleware. The sender must not inspect or reuse a transferred card. Transactional graph encoding
does not consume it if validation or another sibling conversion fails, so a corrected retry remains
possible. Use Permissions to inspect, derive, scope, revoke, and install cards.
Decoding never turns plain data into a card: the card schemas,
Bridge.permissionCardFromSchemaValue, and Bridge.polymorphicPermissionCardFromSchemaValue
accept only cards that the SDK lifted from a host value. The SDK has no conversion API between
PermissionCard, PolymorphicPermissionCard, and ScopeCard. A card that a schema decodes takes
the type that the schema declares, also after an encode in the same component; the host keeps the
real kind of the card, so this gives no authority. A ScopeCard never encodes.
The SDK keeps its card registry on globalThis, because the main and middleware bundles share
cards. Code in the same component can therefore reach the registry and alias a host resource into
two card objects. This does not create authority: the host checks every use, and the second use of
a moved resource fails.
Permission-card management
Permissions wraps golem:[email protected]. Every function returns an Effect that requires
PermissionsClient, which the agent runtime provides. Expected host failures are
PermissionError values whose kind is the WIT case, such as "not-permitted" or
"install-recipient-mismatch". Moved cards, invalid holders, and unexpected host exceptions are
PermissionsHostError values. The id, parents, and expiresAt accessors trap in the host when
the card is unknown or revoked.
import { Effect } from "effect"
import { Permissions } from "@golemcloud/effect-golem"
const grant = {
class: "kv",
owner: "?env",
recipient: "*",
verb: "read",
resourceId: "store.key",
}
const program = Effect.gen(function* () {
const card = yield* Permissions.deriveFromWallet({
lowerPositive: [grant],
upperPositive: [grant],
})
const view = yield* Permissions.inspect(card)
const child = yield* Permissions.derive(
card,
{ lowerPositive: view.lowerPositive, upperPositive: view.upperPositive },
Permissions.Lifespan.fromDuration("1 hour"),
)
const wallet = yield* Permissions.walletScoped
const scope = yield* Permissions.deriveScope(
wallet.map((entry) => entry.card),
{
lowerPositive: [grant],
upperPositive: [grant],
},
)
// Reader is a defineAgent spec whose read method needs the kv grant;
// ReaderClient is the matching defineAgentClient definition.
const reader = yield* Reader.client.get({ name: "reader" })
yield* reader.read.withScopeCard(scope)({ key: "key" })
const holder = yield* Permissions.agentHolder(yield* ReaderClient.agentId({ name: "reader" }))
yield* Permissions.install(child, holder)
return yield* Permissions.revoke(card)
}).pipe(Effect.scoped)walletreturnsWalletCardentries, a closed union labelled with the card kind:{ _tag: "Concrete", card: PermissionCard },{ _tag: "Template", card: PolymorphicPermissionCard }, or{ _tag: "Scope", card: ScopeCard }. AScopeentry is the scope card lent to the current invocation; the host lists it only when the agent holdscard:inspect.deriveandderiveFromWalletreturnPersistentCard(PermissionCard | PolymorphicPermissionCard). The host determines the child kind. UseSchema.is(WitTypes.PermissionCard)orSchema.is(WitTypes.PolymorphicPermissionCard)before passing it to a kind-specific schema. Parent templates resolve in the caller's wallet. Child slots name the eventual holder and the host validates them universally, not just against the caller. Slot tool owners do not resolve aliases using caller metadata.deriveScopereturns aScopeCard, which is a separate type fromPermissionCard. Its parents are persistent cards or scope cards of the current invocation. A scope card supportsid,parents,expiresAt,inspect,release, and use as aderiveScopeparent. It cannot be revoked, installed, derived from withderive, or sent as an agent-method argument or result. Scope derivation rejects slots withinvalid-pattern; scope cards remain concrete.expiresAtof a scope card is the earliest expiry of the root cards of this scope card, or none when no root card expires. The scope card stops authorizing then.- A scope card is valid only during the invocation that derived it. If you keep one, or a client
whose default is one, for a later invocation, the host denies its awaited calls with an
RpcCallErrorwhose cause isdenied. derivetreats the positive bounds as the parent grants to retain and the negative bounds as denials to add.deriveFromWalletandderiveScopetake the complete requested lattice. Omitted bound lists are empty.Lifespan.fromIso8601("P30D")andLifespan.fromDuration(...)build lifespans. The host accepts durations in days and time units (PnDTnHnMnS) and rejects calendar units.fromDurationrejects infinite, zero, and negative durations and sends whole and fractional seconds.id,parents,expiresAt,inspect,derive, andderiveScopeborrow the card.revoke,install, andreleasemove the card before the host call, because the host drops the handle even when the call fails. Any later use fails withPermissionsHostError.CardIdis opaque. Compare IDs withEqual.equalsor use them in Effect collections. Printing, logging, and JSON serialization showCardId(<redacted>).walletreturns owned cards; release the ones that you do not keep withrelease, or usewalletScopedto release them when the scope closes.installacceptsHolder.agent(...)oragentHolder(identity). The host rejects account and app holders withnot-permitted.- Scope cards attach only to awaited calls:
method.withScopeCard(scope)(input)for one call, or thescopeCardoption ofget,getPhantom,newPhantom, andbindWithConfigfor every awaited call of a client.DynamicMethod.invokeand the reflectedinvokeandinvokeValuetake a{ scopeCard }option for one call. Generated Effect bridges haveclient.withScopeCard(scope)for a client default andclient.method.withScopeCard(scope)(...args)for one call. Attaching borrows the card. Triggers, schedules, and tool calls never carry a scope card. - The SDK checks the scope card before it encodes the call input. A released scope card fails the
call with
ScopeCardUnavailableError, and no argument card is moved. - When an awaited call fails or is interrupted before the host receives its input, the SDK gives
the permission cards, secrets, and quota tokens that it encoded back to their handles. A card
released while it was encoded is dropped instead. Streams in the input stay moved, and the SDK closes their wire side. After the
host call starts, nothing is given back, even when the call fails.
DynamicMethod.invokenever changes a caller-supplied tree; on failure the caller still owns its resources.
Restrictive evaluator permissions
Pure agent initialization does not read self metadata and does not require self-view permission.
For an ephemeral evaluator, keep init and the handler independent of host services, declare no
tools or secrets, and keep agent operations outside the initialCard upper bound. Grant only the
operations needed by the evaluator runtime; for nested JavaScript runtime startup, this can include
filesystem stat on the agent's root. The upper bound is a ceiling, not a grant: each required
operation must also be admitted by the effective permission card.
Self metadata is read in these cases:
| Operation | Self-view required? |
| -------------------------------------------------------------------------- | ---------------------------------------------------- |
| Pure initialization and methods without metadata or identity reads | No |
| Agents.getSelfMetadata | Yes |
| SelfAgentId.SelfAgentId, including when used in init | Yes |
| Restoration with an explicit snapshot strategy, including Snapshot.ref() | Yes, to populate the restoration context's agentId |
| Plain schema-state restoration without a snapshot strategy | No implicit self-metadata read |
Every snapshot load also reads GOLEM_AGENT_ID through the WASI environment API,
including plain schema-state restoration. This requires environment-read permission
for that variable independently of self-view; neither permission grants the other.
SelfAgentId.SelfAgentId extracts the identity from the full metadata response; it is not an
identity-only host operation. If self-view is needed for trusted code, the permission target is
agent(?agent) @ ?agent : view : *. The effective card must admit that operation and the
initialCard upper bound must permit it. Do not add this grant merely to initialize a pure evaluator.
Self metadata includes stored environment and configuration fields. The metadata host operation checks agent view permission, but does not apply the per-key env/config permission filters used by WASI reads. An empty or denied WASI environment/configuration read therefore does not establish that those fields are inaccessible through metadata. Keep self-view denied for untrusted code that must not read those fields; do not treat self-view as a harmless bootstrap permission.
Agents.getSelfMetadata returns an AgentsHostError in the Effect error channel and retains
the host failure as its cause. SelfAgentId.SelfAgentId preserves its defect-based failure
contract, including during restoration with an explicit snapshot strategy. Neither path changes
the card or retries with broader permissions. Sagas retain original failures when
compensation fails.
Tool middleware
Standalone middleware imports only the middleware-safe entry point:
import { NoParameters, universal } from "@golemcloud/effect-golem/middleware"
universal({
name: "audit",
parameters: NoParameters,
handler: (invocation, underlying) =>
underlying.invoke(invocation.commandPath, invocation.input, invocation.stdin),
})universal transparently handles any tool using wire values. Every middleware declares an Effect
Schema for installation parameters; decoded values are available as invocation.parameters or
typed-handler context.parameters. NoParameters is the explicit empty-record schema.
typed({ parameters, presented, expected?, handler }) projects typed input/output/error and a definition-derived context.underlying client;
it can present a different definition from the wrapped tool. Both support aliases, docs, and a
per-invocation Effect layer. Underlying access and streams are affine and valid only for that
invocation.
parameters must be a static Effect Schema (streams, secrets, and other installation-time-ineligible capabilities are rejected). underlying.start(...) and typed command .start(...) return scoped started invocations whose get, optional stdout and stderr, and cancel effects are independent. Calls may overlap; consume both outputs and get concurrently when needed. Scope closure disposes the observer and owned streams but does not cancel the tool call—run started.cancel explicitly to cancel it.
When a handler returns, the underlying rejects new admissions but does not implicitly cancel calls already admitted. Cleanup releases their observers after pending observation is safe. For a command declaring stdout or stderr, return/select each stream with the matching typed context.stdout/context.stderr callback (or universal result field). The SDK forwards each into its host-provided writer concurrently, calls finish after clean EOF, and calls fail on forwarding failure; middleware never owns those writers directly.
The single agent-guest build world exports agents, tools, snapshots, and tool middleware. It
supports ordinary, standalone-middleware, and combined components; discovery returns empty lists
for categories a component does not define. Middleware authoring APIs remain available from
@golemcloud/effect-golem/middleware, and invocation-scoped underlying access still advances the
pinned middleware chain. The CLI accepts middleware metadata and manifest attachment; runtime
traversal and invocation behavior remain separate deployment concerns.
Durability 1.6
Durability.wrap and wrapInfallible use
golem:durability/[email protected] custom durable invocation resources. A live call begins with its
typed request and finishes exactly once with a typed response. forcedCommit: true asks the host to
commit after finish. Replay returns the recorded response without executing the body.
Sequential execution only. Both wrappers support properly nested operations and a body that awaits host calls when no unrelated guest work runs during the wait. Do not overlap a custom operation with other fibers, concurrent effects, or JavaScript promises, even if they only use ordinary host APIs. The custom scope remains installed while waiting, so sibling effects can be attributed to the wrong operation and cause incorrect replay or invocation failure. Misuse is not reliably detected. Await each nested operation and do not detach work. Stop and join outstanding work before the body exits, including on failure or interruption; structured finalization alone does not make concurrent custom scopes supported. Ordinary built-in durability is not subject to this restriction when no custom operation is active.
On typed success or typed failure, wrap encodes the response and calls
finishCustomDurableInvocation. On defect, interruption, response encoding failure, or any other
unfinished exit, scope cleanup drops the live invocation instead of finishing it. This is the
1.6 recovery contract: never manufacture a terminal result for work that did not complete.
The four function types are readLocal, writeLocal, readRemote, and writeRemote.
The host records nesting in Start.parent_start_index, not in a function-type payload.
Other durability controls remain under Durability: oplog commits and FunctionType constructors.
Saga provides fallible/infallible compensating transactions; compensations run in reverse order.
Saga.infallibleTransaction compensates typed failures, caught defects and interruption, then asks
the host for a bounded retry decision. Attempts append
new durable calls without deleting prior calls or restoring JavaScript state. Compensate local
state explicitly. Do not reuse a compensated external operation's idempotency key for a new
attempt: the remote peer can suppress that operation. The host resolves the saga policy for the
agent URI after each compensated failure, using attempt and error-kind properties. No selected
policy uses config.retry. The host rejects policy changes and unbounded policies. Exhaustion,
policy errors and incomplete compensation return SagaRetryFailure in the typed error channel.
Compensation runs under an interruption mask. Saga.fallibleTransaction reports complete or
partial compensation instead of retrying, and preserves defects and interruption after complete
rollback. Uncatchable guest traps cannot run guest compensation. Agents.revertAgent remains an external
management operation; management self-revert is unsupported.
Existing Effect integrations
Http: agent HTTP mount/endpoint metadata, auth/CORS/header/query bindings, and verb helpers. The host serves requests.Webhook.createcreates a host webhook and offers typed payload decode.Websocket: scoped host WebSocket client integrated with Effect streams.KeyValueandBlobstore: scoped WASI storage clients with schema-typed views; listing is a stream. Stored decode errors remain typed failures.@golemcloud/effect-golem/sqlite:node:sqliteadapter implementing Effect's unstableSqlClient. Native N-API drivers do not run in the WASM runtime.@golemcloud/effect-golem/postgres,/mysql,/ignite2: host-backed Effect SQL clients with tagged templates, transactions, streaming (for RDBMS), dialect helper values, and optional temporal-to-Datedecoding. Postgres/MySQL nested transactions use savepoints; Ignite rejects nesting.Quota: scoped reservation/commit helpers and affine quota token split/merge.Retry,Oplog,Logging, andTracing: Effect wrappers over their host APIs. Logging/tracing layers are applied automatically to constructors, restoration, handlers, and custom snapshots.
These APIs are covered individually, but complete cross-language 1.6 acceptance is not claimed until the final parity matrix passes.
Build and verify in the monorepo
The base WASM retains the single full agent-guest world and a shared Effect runtime.
The CLI's Rollup configuration uses @golemcloud/effect-golem/build to discover retained
capability modules without executing user code, then generates a static entrypoint. Agent,
tool, and middleware hooks import their implementations only when needed; absent capabilities
have explicit empty discovery and error bodies. No roles or world selection are required.
The component bundle includes only reachable SDK code and adapters, rather than importing
the full SDK from the base WASM. capabilities.json beside the bundle records the selection.
Selection is conservative: a retained definition can keep its capability even when its
registration is conditional or never executed.
Agent, config, and tool definitions are ordinary runtime functions. The build does not evaluate their metadata or rewrite DSL calls; schemas, descriptors, and codecs are constructed by the SDK when the application runs. Metadata may use imported declarations and normal JavaScript computation.
Prerequisites are Node/npm, Rust with wasm32-wasip2, wasm-rquickjs, WASI SDK, and Golem's normal
build prerequisites. From sdks/effect:
The private sdks/http-contract source package is installed by npm ci and bundled by this SDK's
build. No TypeScript SDK installation or sibling SDK build is required. Published packages contain
the helper code and declarations, not a runtime dependency on that private package.
npm ci
npm run lint
npm run format:check
npm run typecheck
npm test
npm run build
npm run build:bundle
$env.WASI_SDK_PATH = "/opt/wasi-sdk"
npm run build-agent-template # builds the default world
npm run check:dts
npm run check:contracts
npm run check:artifactsbuild-agent-template creates/checks agent_guest.wasm. check:artifacts compares
committed/generated provenance and fails on stale artifacts.
Canonical WIT dependencies live at repository-root wit/deps; never edit sdks/effect/wit/deps
by hand. From the repository root:
cargo make wit # mirror canonical WIT into every SDK
cd sdks/effect
npm run generate-dts # regenerate declarations for the default world
npm run check:dts # fail if generated declarations drift
npm run check:artifacts # fail if bundles/templates/WASM driftFor a focused real-runtime check, build the default template first, then run the relevant harness
case under integration-test. Unit tests use injectable host-service layers under src/host; they
do not replace a real WASM integration check.
Durable Streams runtime fixture
The focused durable-streams harness case starts an in-process HTTP peer on an ephemeral local
port. Run it with a local worker executor exposing golem:agent/[email protected]; no Docker
or external stream server is needed. The peer commits the first JSON append but drops its
response, checks the retry's identical producer tuple/body/close, and acknowledges the duplicate
without an offset. The bytes writer appends and then closes on the same resource; the reader
follows an opaque checkpoint across two batches. The case verifies exact JSON/byte payloads
and eleven authenticated HTTP requests, including the cancellation check below.
cancelRead starts a scoped native read whose response the peer holds. A separate control read
confirms that the HTTP request arrived before the guest interrupts the fiber. Interruption must
finish within two seconds with no defect, before the guest asks the peer to release its response.
The peer bounds the hold to ten seconds. readAfterCancel then reads through a fresh resource in
the same agent instance, checking that delayed resource cleanup did not leave the guest unusable.
durable-streams.golem.yaml builds only the fixture and its native-stream sink. It provisions the
test-only secret durableStreamToken. Each method borrows that capability once and shares it
between append and read without revealing it. After building fresh SDK bundles and all templates,
run from sdks/effect/integration-test against a local Golem server:
npm ci
export GOLEM_APP_MANIFEST_PATH="$PWD/durable-streams.golem.yaml"
golem --local --yes build
golem --local --yes deploy
npm run test:integration -- --filter '^durable-streams$' --no-infra --no-server --no-buildTo prepare the consumer without the Golem CLI, bundle only its entrypoint and inject it directly
(from sdks/effect/integration-test):
(cd components/agents && \
GOLEM_APP_ROOT="$PWD/../.." GOLEM_TEMP="$PWD/../../golem-temp" \
GOLEM_COMPONENT_NAME=effect-golem-durable-streams \
GOLEM_COMPONENT_ENTRY=./src/durable-streams-agent.ts \
npx --no rollup -- -c ../../rollup.config.component.mjs)
mkdir -p golem-temp/agents
wasm-rquickjs inject-js --input ../wasm/agent_guest.wasm \
--js golem-temp/ts-dist/effect-golem-durable-streams/main.js \
--output golem-temp/agents/effect_golem_durable_streams.dynamic.wasmExpected results are [["first", "a,b"], ["last"]] and [3, 249, 17]. forwardBytes consumes
the external source in a separate agent through native Preview 3 stream RPC. The cancellation
and subsequent invocation return [29] and [41, 203]. The harness creates
fresh peer state and agent names on every run. This case exercises real host calls and uncertain
acknowledgements, not executor crash recovery. Restart/replay acceptance additionally needs peer
request counters and oplog inspection; returning the same values alone does not prove HTTP was
not repeated. The unit reconstruction test only checks the SDK's deterministic-call assumptions.
Packaging and release convention
The committed package manifest follows the TypeScript SDK convention and stays at 0.0.0; release
automation derives the publish version from a trusted root tag named
golem-effect-v<semver>. The root workflow (not an SDK-local .github workflow) performs a clean
install, all quality gates, all-world builds, declaration/artifact checks, and npm trusted
publishing with provenance.
Before publishing, check:package must pack the package and smoke-test the tarball from a clean
consumer directory, including every public entry point and all three WASM artifacts. A release dry
run validates the tarball without publishing. Publishing remains a separately authorized action.
Host testing seam
Every WIT call is behind a Context.Service wrapper in src/host, assembled once by
HostLive. Public combinators let those requirements flow through their Effect environment; the
dispatcher provides the live layer and erases host services before user handlers are exposed.
Tests provide fake layers rather than mutating module globals. src/host is an internal seam, not a
supported package import.
Compatibility
This is the direct Golem 1.6 API. There are no 1.5 source aliases, old wire fallbacks, or persisted format compatibility promises.
