Migrating TypeScript Agents from the Decorator SDK

SkillAI & models

Migrating a TypeScript Golem agent from the removed decorator/base API (@agent + BaseAgent) to the current schema-driven 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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Migrating TypeScript Agents from the Decorator SDK skill

What this skill tells your AI

The instructions your AI receives, as published by golemcloud/golem in .agents/skills/migrate-ts-decorator-sdk/SKILL.md and read by ahel’s review.

The decorator/base surface of @golemcloud/golem-ts-sdk (@agent() classes extending BaseAgent, @golemcloud/golem-ts-typegen) has been removed. The schema-driven API — defineAgent({...}).implement({...}) — is now the TypeScript authoring surface. This skill maps every old construct to its current replacement.

Verify each API name against sdks/ts/packages/golem-ts-sdk/src/ and sdks/ts/packages/golem-ts-sdk/src/host/ as you go; public APIs are exported from src/index.ts. Migrate directly to the current API. Do not add decorator facades, compatibility wrappers, aliases, or adapters that preserve the removed surface.

Mental model shift

Decorator/baseCurrent SDK
@agent() class extends BaseAgentdefineAgent({ name, id, methods }) (contract) + .implement({ init, methods }) (behaviour)
constructor(name) { super() } paramsid 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 parama bare s.principal() parameter (auto-injected with the per-call caller principal)

There are no agent classes or decorators, and no decorator type-generation step during authoring. Handlers are plain functions whose this is the state. The SDK build still generates and packages runtime/WIT artifacts as part of its normal toolchain.

Imports

The decorator exports are gone. Remove them and the typegen package:

// OLD — delete
import { BaseAgent, agent, prompt, description, endpoint, readonly, Config, Secret, Result } from '@golemcloud/golem-ts-sdk';
// import ... from '@golemcloud/golem-ts-typegen';   // gone entirely

// NEW — import what you use
import { z } from 'zod';                              // or valibot / arktype
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",   // keep
    // "experimentalDecorators": true,   // REMOVE
    // "emitDecoratorMetadata": true,    // REMOVE
    "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() },                       // constructor param → id record
  http: http.mount('/counters/{name}'),           // @agent({ mount }) → http.mount
  methods: {
    increment: method({
      input: {},
      returns: z.number(),
      description: 'Increases the count by one and returns the new value',   // @description
      promptHint: 'Increase the count by one',                              // @prompt
      http: http.post('/increment'),                                        // @endpoint({ post })
    }),
  },
});

export const CounterImpl = Counter.implement({
  init: ({ id }) => ({ name: id.name, value: 0 }),  // class fields → init() state
  methods: {
    increment() { this.value += 1; return this.value; },  // `this` is the state
  },
});

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 @descriptiondefineAgent({ 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()) }),
// handler:
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(),                  // local  → this.config.greeting : string (read fresh)
    apiKey: s.secret(z.string()),          // secret → this.config.apiKey : Secret<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); },   // Secret<T>.get() reveals fresh
  },
});

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:

// Typed, scoped: only the declared fields of `this` are serialized.
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() { return new Uint8Array(/* serialize this */); },
    load(bytes) { /* restore this from bytes */ },
  },
});

Use method syntax for custom snapshot callbacks when they access state; arrow functions do not bind the implementation's state as this.

Current Replacements and Known Deltas

Use the current forms below and verify them against the SDK source rather than reconstructing the removed decorator surface.

Current forms:

  • 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, undefined, overrides). For a fresh phantom agent, use clientFor(Def).newPhantom(id, overrides). Non-secret override leaves are encoded and applied at call time; secret overrides are rejected because secrets remain host-provisioned.
  • Cancelable / abortable RPC. Pass { signal } to an awaited client method, for example await client.run(input, { signal }). client.run.schedule(at, input) returns a CancellationToken whose .cancel() cancels the scheduled invocation. Fire-and-forget remains client.run.trigger(input). There are no .abortable or .scheduleCancelable client methods.
  • 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 replacement for the decorator's injected Principal parameter). this.getPrincipal() still reads the init-time principal.

Known remaining deltas (verify against src/ before assuming this list is exhaustive):

  • 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 current equivalent, stop and flag it rather than inventing an API name — verify against src/ and src/host/ first.

Migration checklist

  1. Delete decorator imports and any @golemcloud/golem-ts-typegen usage.
  2. Remove experimentalDecorators / emitDecoratorMetadata from tsconfig.json.
  3. Convert each @agent class to defineAgent({...}) + .implement({...}); constructor params → id; class fields → init() state.
  4. Convert method signatures to method({ input, returns }) with Standard Schema; @description/@prompt/@readonly/@endpoint → method options + http.mount.
  5. Convert Result<T,E> returns to s.result(ok, err).
  6. Convert Config/Secret constructor params to a config record (s.secret(...) for secrets).
  7. Convert save/loadSnapshot overrides to snapshotting (or .implement({ snapshot })).
  8. Ensure every agent module is imported from src/main.ts.
  9. Remove the old decorator/base/typegen dependencies and all compatibility wrappers.
  10. Build the local SDK bundle and agent template, then point the application build at those local packages before verifying:
cd /path/to/golem/sdks/ts
npx pnpm install
npx pnpm run build
npx pnpm run build-agent-template
cd /path/to/migrated-application
GOLEM_TS_PACKAGES_PATH=/path/to/golem/sdks/ts/packages golem build --yes

Signals

GitHub stars
2k
Forks
212
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
migrate-ts-decorator-sdk
Source
github.com/golemcloud/golem