Adding HTTP Endpoints to an Effect Golem Agent
Effect agents publish HTTP route metadata with the Http namespace from
@golemcloud/effect-golem. The Golem host serves the routes, decodes path, query, header, and JSON
body values into method parameters, invokes the agent, and maps the method result to an HTTP
response.
Steps
- Define the agent and its methods with
defineAgent,method, and Effect Schema. - Add
http: Http.mount(...)to the agent definition. - Add an
http: [Http.get(...) | Http.post(...) | ...]array to every exposed method. - Implement every handler as an
Effectand import its module fromsrc/main.ts. - Add the agent type to an
httpApidomain deployment ingolem.yaml. - Run
golem build, then deploy withgolem deploy --yes.
Related Guides
| Guide | Description |
|---|---|
golem-add-agent-effect | Defining the Effect agent, method schemas, and durable state |
golem-http-params-effect | Detailed path, query, header, body, and response mapping |
golem-make-http-request-effect | Making outgoing HTTP requests from an Effect handler |
golem-add-http-auth-effect | Enabling authentication and reading the caller principal |
golem-add-cors-effect | Configuring mount-level or endpoint-level CORS |
golem-configure-api-domain | Configuring the httpApi deployment domain |
Mount Path
Import Http from the Effect Golem SDK and put one mount on the agent definition:
import { Effect, Schema } from "effect";
import { defineAgent, Http, method } from "@golemcloud/effect-golem";
export const TaskAgent = defineAgent({
name: "TaskAgent",
mode: "durable",
id: {
taskName: Schema.String,
},
http: Http.mount("/api/tasks/{taskName}"),
methods: {
// ...
},
}).implement({
init: () => Effect.void,
methods: () => ({
// ...
}),
});Mount rules:
- The path starts with
/and does not end with/unless it is exactly/. - Every agent id field appears as a
{variable}in the mount path. - Variable names use the exact TypeScript
idkeys, including casing. - Mount paths cannot contain query parameters or
{*rest}catch-all variables. - Use
{taskName}, not{task-name}, for a constructor field namedtaskName. This changes only the placeholder name; both forms would match the same concrete URL segment.
For a fresh phantom agent instance per HTTP request, set the mount option rather than changing the agent mode:
http: Http.mount("/gateway/{name}", { phantomAgent: true }),mode still accepts only "durable" or "ephemeral"; it is independent of
phantomAgent.
Endpoint Declarations
Declare routes on the corresponding method. Endpoint paths are relative to the mount:
methods: {
listItems: method({
input: {},
success: Schema.Array(Item),
http: [Http.get("/items")],
}),
createItem: method({
input: {
name: Schema.String,
count: Schema.Number,
},
success: Item,
http: [Http.post("/items")],
}),
updateItem: method({
input: {
id: Schema.String,
name: Schema.String,
},
success: Schema.NullOr(Item),
http: [Http.put("/items/{id}")],
}),
deleteItem: method({
input: { id: Schema.String },
success: Schema.Void,
http: [Http.del("/items/{id}")],
}),
},Use these exact helpers:
| HTTP route | Effect Golem helper |
|---|---|
| GET | Http.get(path, options?) |
| POST | Http.post(path, options?) |
| PUT | Http.put(path, options?) |
| DELETE | Http.del(path, options?) |
| Custom method | Http.custom("METHOD", path, options?) |
One method can have multiple routes by adding multiple entries to its http array.
Parameter Mapping
Bindings always refer to exact method input keys:
searchItems: method({
input: {
category: Schema.String,
query: Schema.String,
minPrice: Schema.NullOr(Schema.Number),
tenant: Schema.String,
},
success: Schema.Array(Item),
http: [
Http.get(
"/categories/{category}/search?q={query}&min-price={minPrice}",
{ headers: { "X-Tenant": "tenant" } as const },
),
],
}),{category}binds a path segment toparams.category.q={query}binds query keyqtoparams.query; the URL key and TypeScript variable may have different names.- Missing optional query or header values decode to
nullwhen declared withSchema.NullOr(...). headersmaps HTTP header names to method parameter names.- A parameter can be bound from only one of path, query, or headers in a given endpoint.
Http.gethas no body, so every parameter must be bound explicitly.- For ordinary structured bodies, each unbound user-supplied parameter comes from a JSON object
field with the same TypeScript name. For example, unbound
inStockexpects{ "inStock": true }. A single object input namedrequestneeds{ "request": { "text": "hello" } }, not the bare inner object. Unstructured text and binary inputs instead consume raw bodies. Loadgolem-http-params-effectfor the body mapping rules and the contrast with CLI arguments.
Endpoint paths start with /. A {*rest} catch-all is allowed only as the final endpoint path
segment.
Durable Stream Route Customization
For a method with stream inputs or outputs, pass durableStreams in the Http endpoint options, or pipe the endpoint through Http.withDurableStreams(...):
http: [Http.post("/events", {
durableStreams: {
slots: [
{ source: "input", slot: "input", name: "uploads" },
{
source: "output",
slot: "$result",
name: "events",
contentType: "application/vnd.example.events",
},
],
load: {
maxConcurrentReadersPerStream: 8,
maxAppendRequestsPerSecondPerStream: 25,
},
},
})],sourceandslotselect canonical top-level stream slots.$resultis the only implicit result selector.namechanges the public URL and OpenAPI name; the canonical name is no longer accepted in the public URL.contentTypeis allowed only for a direct byte stream (stream<u8>) and must be a concrete non-text, non-JSON MIME type without parameters or wildcards. JSON-shaped streams stayapplication/json; do not usetext/plainforstream<string>. SSE still usestext/event-streamand base64 data.- Reader and append limits must be positive and no greater than the installation maximums. Omitted values use the registry installation defaults.
- Limits are maintained per route and worker-service node, not as a cluster-wide quota.
HTTP Response Mapping
The Golem HTTP host maps Effect method result schemas as follows:
| Method success schema | Handler result | HTTP response |
|---|---|---|
Schema.Void | Effect.void | 204, empty body |
T | Effect<T> | 200, JSON T |
UnstructuredText(...) | inline text reference | 200, plain text |
UnstructuredBinary(...) | inline binary reference | 200, raw bytes |
UnstructuredText/Binary(...) | { _tag: "url", val } reference | 307 redirect to val |
Schema.NullOr(T) | T | 200, JSON T |
Schema.NullOr(T) | null | 404, empty body |
Unstructured.Option(element) | Option.some(ref) / Option.none() | 200 raw body / 404 |
method with error: E | Effect.fail(E) | 500, JSON E |
Schema.NullOr(T) is lowered by the Effect SDK to WIT option<T>, which is what enables the
host’s 200/404 mapping. A declared error: E wraps the success in result<_, E>, so
Schema.NullOr(T) or Unstructured.Option(...) combined with error: E gives 200 / 404 / 500.
Use a declared error schema and Effect.fail(...) for expected typed failures; do not use
defects for normal not-found behavior.
Use an unstructured success value when the response itself must be plain text or binary rather than JSON:
import { Unstructured } from "@golemcloud/effect-golem";
const textSuccess = Unstructured.UnstructuredText({
restrictions: [{ languageCode: "en" }],
});
const binarySuccess = Unstructured.UnstructuredBinary({
restrictions: [{ mimeType: "application/octet-stream" }],
});
const textValue = {
_tag: "inline" as const,
val: "hello from Effect",
languageCode: "en",
} satisfies Unstructured.TextReferenceValue;
const binaryValue = {
_tag: "inline" as const,
val: new Uint8Array([0, 127, 255]),
mimeType: "application/octet-stream",
} satisfies Unstructured.BinaryReferenceValue;Put textSuccess or binarySuccess in the method’s success field and return
Effect.succeed(textValue) or Effect.succeed(binaryValue) from its handler. Inline text maps to a
text/plain body and uses languageCode as Content-Language; inline binary uses its
mimeType (default application/octet-stream). A { _tag: "url", val } reference maps to a
307 redirect.
An unstructured success combines with a typed error, and Unstructured.Option(element) makes it
optional. The nested value is still sent as the raw body, not as JSON. A guarded raw download
(raw bytes / 404 / JSON error 500):
import { Effect, Option, Schema } from "effect";
import { Http, method, Unstructured } from "@golemcloud/effect-golem";
const Pending = Schema.Struct({ _tag: Schema.Literal("Pending") });
const download = method({
input: { id: Schema.String },
success: Unstructured.Option(
Unstructured.UnstructuredBinary({ restrictions: [{ mimeType: "image/png" }] }),
),
error: Pending,
http: [Http.get("/files/{id}")],
});
// In the implementation:
const downloadHandler = ({ id }: { id: string }) =>
id === "missing"
? Effect.succeed(Option.none()) // 404
: id === "pending"
? Effect.fail({ _tag: "Pending" as const }) // 500, JSON body
: Effect.succeed(
Option.some({ _tag: "inline" as const, val: new Uint8Array([1, 2, 3]), mimeType: "image/png" }),
); // 200, raw bytesUnstructured.Option(...) is only valid as a method success. Use Schema.Struct(...), arrays,
and other ordinary schemas for JSON responses, and Schema.Void for a 204 empty response. These
are fixed host mappings, not an arbitrary response builder.
Complete Durable Example
import { Effect, Ref, Schema } from "effect";
import { defineAgent, Http, method, Snapshot } from "@golemcloud/effect-golem";
const TodoItem = Schema.Struct({
id: Schema.String,
title: Schema.String,
done: Schema.Boolean,
});
const TodoState = Schema.Struct({
items: Schema.Array(TodoItem),
});
export const TodoAgent = defineAgent({
name: "TodoAgent",
mode: "durable",
id: {
listName: Schema.String,
},
http: Http.mount("/todos/{listName}"),
snapshotting: Snapshot.define({
schema: TodoState,
policy: Snapshot.policy.everyN(10),
}),
methods: {
createItem: method({
input: { title: Schema.String },
success: TodoItem,
http: [Http.post("/items")],
}),
listItems: method({
input: {},
success: Schema.Array(TodoItem),
http: [Http.get("/items")],
}),
completeItem: method({
input: { id: Schema.String },
success: Schema.NullOr(TodoItem),
http: [Http.post("/items/{id}/complete")],
}),
},
}).implement({
init: () => Ref.make({ items: [] as ReadonlyArray<typeof TodoItem.Type> }),
methods: (state) => ({
createItem: ({ title }) =>
Ref.modify(state, ({ items }) => {
const item = {
id: String(items.length + 1),
title,
done: false,
};
return [item, { items: [...items, item] }] as const;
}),
listItems: () => Ref.get(state).pipe(Effect.map(({ items }) => items)),
completeItem: ({ id }) =>
Ref.modify(state, ({ items }) => {
const existing = items.find((item) => item.id === id);
if (existing === undefined) return [null, { items }] as const;
const updated = { ...existing, done: true };
return [
updated,
{
items: items.map((item) => (item.id === id ? updated : item)),
},
] as const;
}),
}),
snapshot: Snapshot.ref<{ items: ReadonlyArray<typeof TodoItem.Type> }>(),
});Register the top-level implementation:
// src/main.ts
import "./todo-agent.js";Local imports use the emitted .js suffix in generated ESM projects.
Domain Deployment
Add the agent to the existing httpApi deployment without removing other agents:
httpApi:
deployments:
local:
- domain: my-app.localhost:9006
agents:
TodoAgent: {}The httpApi section in current Golem manifests contains deployment configuration only. Do not add legacy
apiDefinitions, route lists, OpenAPI extension bindings, or Rib response scripts. Route metadata
comes from Http.mount(...) and the method-level Http.*(...) declarations. Golem serves the
generated OpenAPI document at /openapi.yaml after deployment.
Key Constraints
- Import Effect APIs from
effectanddefineAgent,Http,method, andSnapshotfrom@golemcloud/effect-golem. - Do not use decorators or classes from
@golemcloud/golem-ts-sdkin an Effect component. - Every agent id field must appear in the mount path with exact TypeScript casing.
- Every bound path, query, or header variable must match a method parameter.
- Unbound bodyful-method parameters use same-named camelCase JSON body fields.
- Handlers return Effects, not plain values or
asyncfunctions. - Use
Snapshot.ref<Saved>()when a snapshottedRefcontains the saved schema value. - Import the implementation module from
src/main.ts; otherwise it is not registered. - Do not edit generated files under
golem-temp/.