| name | migrate-ts-base-to-fluent |
| description | Migrating a TypeScript Golem agent from the OLD decorator/base API (@agent + BaseAgent) to the fluent (Standard Schema) defineAgent API. Use when porting an existing decorator-based TS agent, when you see @agent()/extends BaseAgent/@endpoint/@description code that no longer compiles, or when asked to modernize a TS component to the current SDK. |
Migrating TypeScript Agents: base/decorator → fluent
The decorator/base surface of @golemcloud/golem-ts-sdk (@agent() classes extending BaseAgent, @golemcloud/golem-ts-typegen) has been removed. The fluent (Standard Schema) API — defineAgent({...}).implement({...}) — is now the only ts authoring surface. This skill maps every old construct to its fluent replacement.
Verify each API name against the SDK source in sdks/ts/packages/golem-ts-sdk/src/fluent/ and src/host/ as you go; the fluent exports are re-exported from the package root (src/index.ts → export * from './fluent').
Mental model shift
| Decorator/base | Fluent |
|---|
@agent() class extends BaseAgent | defineAgent({ name, id, methods }) (contract) + .implement({ init, methods }) (behaviour) |
constructor(name) { super() } params | id record on defineAgent; the values become the constructor parameters |
private class fields (this.value = 0) | state object returned by init(); read/write via this in handlers |
method signature types (increment(): Promise<number>) | method({ input: {...}, returns: <schema> }) with Standard Schema |
this.getId() | this.getId() (same, on the handler this) |
getPrincipal() | this.getPrincipal() |
injected Principal constructor/method param | a bare s.principal() parameter (auto-injected with the per-call caller principal) |
There are no classes, no decorators, and no code generation. Handlers are plain functions whose this is the state.
Imports
The decorator exports are gone. Remove them and the typegen package:
import { BaseAgent, agent, prompt, description, endpoint, readonly, Config, Secret, Result } from '@golemcloud/golem-ts-sdk';
import { z } from 'zod';
import { defineAgent, method, s, http, clientFor, Result } from '@golemcloud/golem-ts-sdk';
Result still exists (host Result.ok / Result.err). Config and Secret as constructor parameter types are gone — config is now a config record on defineAgent and secrets are s.secret(...) markers surfaced as Secret<T> handles on this.config.
tsconfig
Drop the decorator flags; keep bundler resolution:
{
"compilerOptions": {
"moduleResolution": "bundler",
"strict": true,
"useDefineForClassFields": false
}
}
Agent class → defineAgent + implement
OLD:
@agent({ mount: '/counters/{name}' })
class CounterAgent extends BaseAgent {
private readonly name: string;
private value: number = 0;
constructor(name: string) { super(); this.name = name; }
@prompt('Increase the count by one')
@description('Increases the count by one and returns the new value')
@endpoint({ post: '/increment' })
async increment(): Promise<number> {
this.value += 1;
return this.value;
}
}
NEW:
import { z } from 'zod';
import { defineAgent, method, http } from '@golemcloud/golem-ts-sdk';
export const Counter = defineAgent({
name: 'CounterAgent',
id: { name: z.string() },
http: http.mount('/counters/{name}'),
methods: {
increment: method({
input: {},
returns: z.number(),
description: 'Increases the count by one and returns the new value',
promptHint: 'Increase the count by one',
http: http.post('/increment'),
}),
},
});
export const CounterImpl = Counter.implement({
init: ({ id }) => ({ name: id.name, value: 0 }),
methods: {
increment() { this.value += 1; return this.value; },
},
});
Register the agent by importing its module from src/main.ts (import './counter-agent.js';) — there is no exported class for the runtime to find; defineAgent/.implement register at module load.
Annotations → method metadata + HTTP
@description('...') → method({ description: '...' }); agent-level @description → defineAgent({ description }).
@prompt('...') → method({ promptHint: '...' }); agent-level → defineAgent({ promptHint }).
@readonly() → method({ readOnly: true }); @readonly({ cache, usesPrincipal }) → method({ readOnly: { cache: 'no-cache' | 'until-write' | { ttlNanos }, usesPrincipal } }).
@endpoint({ post: '/x' }) → method({ http: http.post('/x') }), with a mount declared via defineAgent({ http: http.mount('/prefix/{idVar}', { cors, auth }) }).
@agent({ mount, cors }) → defineAgent({ http: http.mount(mount, { cors }) }).
Verbs: http.get/head/post/put/del/patch/options/connect/trace, plus http.custom(verb, path). Query binds via inline ?k={var}; headers via { headers: { 'X-Name': 'param' } }.
Result<T,E> return → s.result(ok, err)
OLD async m(): Promise<Result<T, E>> returning Result.ok/Result.err becomes a typed returns:
divide: method({ input: { a: z.number(), b: z.number() }, returns: s.result(z.number(), z.string()) }),
divide({ a, b }) { return b === 0 ? Result.err('div by zero') : Result.ok(a / b); }
The failure travels as a value inside the success payload (same semantics as the decorator SDK). ok(...) / err(...) are Result.ok(...) / Result.err(...).
Config / Secret constructor params → config record
OLD passed Config<AgentConfig> (with nested Secret<T> fields) as a constructor parameter. NEW declares a config record on defineAgent; wrap any secret field (at any depth) in s.secret(inner):
export const ConfigAgent = defineAgent({
name: 'ConfigAgent',
id: { name: z.string() },
config: {
greeting: z.string(),
apiKey: s.secret(z.string()),
nested: z.object({ a: z.string(), c: s.secret(z.object({ d: z.string() })) }),
},
methods: { keyTail: method({ input: {}, returns: z.string() }) },
});
export const ConfigAgentImpl = ConfigAgent.implement({
init: () => ({}),
methods: {
keyTail() { return this.config.apiKey.get().slice(-4); },
},
});
Local fields read their decoded value directly off this.config; secret fields are lazy log-safe Secret<T> handles — call .get() at the point of use, never log them. Only object schemas are flattened into nested config fields; unions/arrays/maps are read whole.
save/loadSnapshot overrides → snapshotting option
OLD overrode saveSnapshot() / loadSnapshot() on the BaseAgent subclass. NEW is declarative on defineAgent:
snapshotting: { state: z.object({ count: z.number() }), policy: { everyNInvocations: 5 } },
Policy: 'disabled' (default) | 'default' | { everyNInvocations: n } | { periodicSeconds: n }. A bare policy (no state) falls back to reflective JSON serialization of the whole state.
For fully custom bytes, supply a snapshot block on .implement(...) — this is the state:
Def.implement({
init: () => ({ }),
methods: { },
snapshot: {
save: () => new Uint8Array(),
load: (bytes) => { },
},
});
Feature parity — restored capabilities + the remaining deltas
The fluent API now covers the decorator features it once lacked. Use the fluent forms below; only two genuine deltas remain.
Restored to base parity (use these — they are NOT gaps):
readOnly cache policies. @readonly({ cache: 'no-cache' | 'until-write' | { ttl } }) → method({ readOnly: { cache: 'no-cache' | 'until-write' | { ttlNanos: <bigint> }, usesPrincipal?: boolean } }). Bare readOnly: true uses the until-write policy (the base default); principal-dependent caching → usesPrincipal: true.
- Config-on-RPC (
getWithConfig). Agent.getWithConfig(id, overrides) → clientFor(Def)(id, phantomId?, overrides) — the non-secret override leaves are encoded and applied to the remote agent at call time (secret overrides are rejected; secrets stay host-provisioned).
- Cancelable / abortable RPC. The client method has
.abortable(signal, input) (cancels the remote invocation when the AbortSignal fires) and .scheduleCancelable(at, input) → a CancellationToken (.cancel()), alongside .trigger(input) / .schedule(at, input).
Principal as data + auto-injected input. s.principal() carries a Principal as a method return / nested value, and a bare s.principal() method (or constructor) PARAMETER is auto-injected with the per-call caller principal — it consumes no wire field and is not bound from HTTP/RPC callers (the fluent equivalent of the decorator's injected Principal parameter). this.getPrincipal() still reads the init-time principal.
Remaining deltas (verify against src/fluent/ before assuming an option exists):
- Custom snapshot is a bare
Uint8Array. No { data, mimeType } return, no application/json vs multipart/mixed selection, and no automatic SQLite multipart part. save returns bytes, load takes bytes.
- No mount-level header→id binding. The mount only binds path
{var} names to id fields; there is no header-to-id mapping on the mount (an endpoint-level { headers } binding on a method DOES work).
If a decorator feature you need has no fluent equivalent, stop and flag it rather than inventing an API name — verify against src/fluent/ and src/host/ first.
Migration checklist
- Delete decorator imports and any
@golemcloud/golem-ts-typegen usage.
- Remove
experimentalDecorators / emitDecoratorMetadata from tsconfig.json.
- Convert each
@agent class to defineAgent({...}) + .implement({...}); constructor params → id; class fields → init() state.
- Convert method signatures to
method({ input, returns }) with Standard Schema; @description/@prompt/@readonly/@endpoint → method options + http.mount.
- Convert
Result<T,E> returns to s.result(ok, err).
- Convert
Config/Secret constructor params to a config record (s.secret(...) for secrets).
- Convert
save/loadSnapshot overrides to snapshotting (or .implement({ snapshot })).
- Ensure every agent module is imported from
src/main.ts.
golem build --yes to verify.
Archiving / sharing a migration branch off GitHub (git bundle)
For private marketing branches or migration work kept off GitHub, use a git bundle — a single-file archive of a branch (or range) that can be copied around and cloned like a remote.
# Create a bundle of just the commits on <branch> since <base> (e.g. main..feature)
git bundle create migrate-ts-fluent.bundle main..migrate-ts-fluent
# Or bundle a whole branch (self-contained, clonable):
git bundle create migrate-ts-fluent.bundle migrate-ts-fluent
# Verify the bundle is intact and lists its prerequisites/refs
git bundle verify migrate-ts-fluent.bundle
# Consume it: clone (or fetch/pull) directly from the file
git clone migrate-ts-fluent.bundle restored-repo
git fetch ./migrate-ts-fluent.bundle migrate-ts-fluent # into an existing repo
A range bundle (main..branch) is smaller but requires the recipient already have main; a full-branch bundle is self-contained and clonable on its own. Never git stash in a shared Golem checkout to stage this work — use a worktree or local commits.