Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions strands-agents/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
lib
.eslintrc.js
43 changes: 43 additions & 0 deletions strands-agents/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const { builtinModules } = require('module');

const ALLOWED_NODE_BUILTINS = new Set(['assert']);

module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint', 'deprecation'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'deprecation/deprecation': 'warn',
'object-shorthand': ['error', 'always'],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'off',
},
overrides: [
{
files: ['src/workflows.ts', 'src/workflows-*.ts', 'src/workflows/*.ts'],
rules: {
'no-restricted-imports': [
'error',
...builtinModules.filter((m) => !ALLOWED_NODE_BUILTINS.has(m)).flatMap((m) => [m, `node:${m}`]),
],
},
},
],
};
80 changes: 80 additions & 0 deletions strands-agents/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Strands Agents

A complete set of samples for the Temporal TypeScript SDK's Strands integration, mirroring the Python samples in [`samples-python/strands_plugin`](https://github.com/temporalio/samples-python/tree/strands-plugin-samples/strands_plugin).

One worker registers all activities, all workflows, and an MCP server. Each feature has its own client script in `src/`:

| Feature | Client | What it demonstrates |
| --- | --- | --- |
| Hello World | `npm run workflow:hello-world` | Minimal `TemporalAgent` invocation. |
| Tools | `npm run workflow:tools` | Pure Strands `tool()` + `workflow.activityAsTool` for I/O. |
| Human in the Loop | `npm run workflow:human-in-the-loop` | Gate a tool call on human approval with a `BeforeToolCallEvent` hook + `event.interrupt()`. |
| Activity Interrupt | `npm run workflow:activity-interrupt` | Activity-thrown interrupt routed through `StrandsFailureConverter`. |
| Hooks | `npm run workflow:hooks` | `AfterToolCallEvent` with in-workflow and activity-dispatched callbacks. |
| MCP | `npm run workflow:mcp` | Connect to a stdio MCP server via `TemporalMCPClient`. |
| Structured Output | `npm run workflow:structured-output` | Constrain output with a Zod schema. |
| Streaming | `npm run workflow:streaming` | Forward model chunks to subscribers via `WorkflowStream`. |
| Continue as New | `npm run workflow:continue-as-new` | Long-lived chat with history-aware continue-as-new. |

### Directory layout

```
src/
├── worker.ts # one worker, registers everything
├── workflows.ts # re-exports every workflow in workflows/
├── activities.ts # re-exports every activity in activities/
├── mcp-server.ts # stdio MCP server (used by the mcp feature)
├── <feature>.ts # one client script per feature
├── workflows/
│ └── <feature>.ts # workflow definition for that feature
├── activities/
│ └── <feature>.ts # activities for that feature (only where needed)
└── mocha/
├── stub-model.ts # shared StubModel for tests
└── <feature>.test.ts # one test per feature
```

### Prerequisites

`@temporalio/strands-agents` and its dependency `@temporalio/workflow-streams` live in [`temporalio/sdk-typescript`](https://github.com/temporalio/sdk-typescript) on the `strands` branch and are not yet published to npm. `package.json` references both as `file:` tarballs, assuming `sdk-typescript` is checked out next to `samples-typescript`:

```
<your-workspace>/
├── samples-typescript/ # this repo
└── sdk-typescript/ # the SDK repo on branch `strands`
```

Before installing, build the contrib packages and produce the tarballs (the `workspace:*` deps inside them need to be substituted with concrete versions, which `pnpm pack` does automatically):

```bash
cd /path/to/sdk-typescript
git checkout strands
pnpm install
pnpm build

cd contrib/strands && pnpm pack && cd ../..
cd contrib/workflow-streams && pnpm pack && cd ../..
# `@temporalio/common` needs to be packed too because the streaming feature
# relies on the `INTERNAL_HANDLER_NAME_ALLOWLIST` added on the `strands` branch
# but not yet published to npm. The sample's `package.json` overrides every
# transitive `@temporalio/common` with this tarball.
cd packages/common && pnpm pack && cd ../..
```

### Running

1. `npm install` in this directory.
1. Configure AWS credentials so the default `BedrockModel` can reach Amazon Bedrock (`AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`). The default model is `global.anthropic.claude-sonnet-4-6`, which requires submitting Anthropic's use-case details form — pick the model in the [Bedrock model catalog](https://console.aws.amazon.com/bedrock/home#/model-catalog) and fill out the form before invoking. To use a different provider — or pin a model that doesn't require the form — swap `BedrockModel` in `src/worker.ts` for `AnthropicModel`, `OpenAIModel`, etc., from `@strands-agents/sdk`, or pass a `modelId` to `BedrockModel`.
1. `temporal server start-dev`.
1. `npm run start.watch` in one shell to start the worker.
1. `npm run workflow:<feature>` in another shell — see the table above.

### Tests

`src/mocha/*.test.ts` exercises each workflow with a `StubModel` and `TestWorkflowEnvironment`, so the suite runs without AWS credentials:

```bash
npm test
```

> The `mocha@8` version pinned across `samples-typescript` is incompatible with Node 24+ (its bundled `yargs` is loaded as ESM and breaks at startup). Use Node 22 to run tests, matching the rest of `samples-typescript`.
64 changes: 64 additions & 0 deletions strands-agents/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"name": "temporal-strands-agents",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc --build",
"build.watch": "tsc --build --watch",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"start": "ts-node src/worker.ts",
"start.watch": "nodemon src/worker.ts",
"workflow:hello-world": "ts-node src/hello-world.ts",
"workflow:tools": "ts-node src/tools.ts",
"workflow:human-in-the-loop": "ts-node src/human-in-the-loop.ts",
"workflow:activity-interrupt": "ts-node src/activity-interrupt.ts",
"workflow:hooks": "ts-node src/hooks.ts",
"workflow:mcp": "ts-node src/mcp.ts",
"workflow:structured-output": "ts-node src/structured-output.ts",
"workflow:streaming": "ts-node src/streaming.ts",
"workflow:continue-as-new": "ts-node src/continue-as-new.ts",
"test": "mocha --exit --require ts-node/register --require source-map-support/register src/mocha/*.test.ts"
},
"nodemonConfig": {
"execMap": {
"ts": "ts-node"
},
"ext": "ts",
"watch": [
"src"
]
},
"overrides": {
"@temporalio/common": "file:../../sdk-typescript/packages/common/temporalio-common-1.17.2.tgz"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.2",
"@strands-agents/sdk": "^1.3.0",
"@temporalio/activity": "^1.17.2",
"@temporalio/client": "^1.17.2",
"@temporalio/envconfig": "^1.17.2",
"@temporalio/strands-agents": "file:../../sdk-typescript/contrib/strands/temporalio-strands-agents-1.17.2.tgz",
"@temporalio/worker": "^1.17.2",
"@temporalio/workflow": "^1.17.2",
"@temporalio/workflow-streams": "file:../../sdk-typescript/contrib/workflow-streams/temporalio-workflow-streams-1.17.0.tgz",
"zod": "^4.1.12"
},
"devDependencies": {
"@temporalio/testing": "^1.17.2",
"@tsconfig/node22": "^22.0.0",
"@types/mocha": "8.x",
"@types/node": "^22.9.1",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"mocha": "8.x",
"nodemon": "^3.1.7",
"prettier": "^3.4.2",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}
3 changes: 3 additions & 0 deletions strands-agents/src/activities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { fetchWeather } from './activities/tools';
export { deleteThing } from './activities/activity-interrupt';
export { persistToolCall } from './activities/hooks';
31 changes: 31 additions & 0 deletions strands-agents/src/activities/activity-interrupt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ApplicationFailure } from '@temporalio/common';
import { STRANDS_INTERRUPT_TYPE } from '@temporalio/strands-agents';

const APPROVED = new Set<string>();

export async function deleteThing(input: { name: string }): Promise<string> {
if (!APPROVED.has(input.name)) {
// First attempt: mark the name as approved on the way out (simulating the
// human flipping a flag during the interrupt pause) and stop the agent by
// raising an interrupt-shaped failure. The plugin's `StrandsFailureConverter`
// would also recognize a thrown `{ interrupts: [{ toJSON: () => ... }] }`,
// but throwing `ApplicationFailure` directly avoids any chance of the
// converter being skipped (and keeps `nonRetryable: true` so the workflow
// sees the interrupt instead of a retry-then-success).
APPROVED.add(input.name);
throw ApplicationFailure.create({
message: 'interrupt:approval',
type: STRANDS_INTERRUPT_TYPE,
nonRetryable: true,
details: [
{
id: `delete:${input.name}`,
name: 'approval',
reason: `approve delete of protected resource '${input.name}'?`,
source: 'tool',
},
],
});
}
return `deleted ${input.name}`;
}
6 changes: 6 additions & 0 deletions strands-agents/src/activities/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { log } from '@temporalio/activity';

export async function persistToolCall(toolName: string): Promise<void> {
// In production, write to a database / S3 / your audit pipeline.
log.info(`audit: tool ${toolName} completed`);
}
7 changes: 7 additions & 0 deletions strands-agents/src/activities/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export async function fetchWeather(input: { city: string }): Promise<{ city: string; temperatureF: number; conditions: string }> {
return {
city: input.city,
temperatureF: 72,
conditions: 'sunny',
};
}
39 changes: 39 additions & 0 deletions strands-agents/src/activity-interrupt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Client, Connection } from '@temporalio/client';
import { StrandsPlugin } from '@temporalio/strands-agents';
import {
activityInterrupt,
activityInterruptApproveSignal,
activityInterruptPendingApprovalQuery,
} from './workflows';

async function run() {
const connection = await Connection.connect({
address: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
});
// The starter goes through the plugin's failure converter so an Interrupt
// payload deserializes cleanly if it propagates to the client.
const client = new Client({ connection, plugins: [new StrandsPlugin()] });

const handle = await client.workflow.start(activityInterrupt, {
args: ["Please delete the 'system' user."],
taskQueue: 'strands-agents',
workflowId: 'strands-activity-interrupt',
});

let reason: string | null = null;
while (reason === null) {
await new Promise((resolve) => setTimeout(resolve, 500));
reason = await handle.query(activityInterruptPendingApprovalQuery);
}
console.log(`Approval requested: ${reason}`);

await handle.signal(activityInterruptApproveSignal, 'approve');

const result = await handle.result();
console.log(`Result: ${result}`);
}

run().catch((err) => {
console.error(err);
process.exit(1);
});
28 changes: 28 additions & 0 deletions strands-agents/src/continue-as-new.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Client, Connection } from '@temporalio/client';
import { chatEnd, chatTurn, chatWorkflow } from './workflows';

async function run() {
const connection = await Connection.connect({
address: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
});
const client = new Client({ connection });

const handle = await client.workflow.start(chatWorkflow, {
taskQueue: 'strands-agents',
workflowId: 'strands-continue-as-new',
});

for (const prompt of ['Hi! What is durable execution?', 'Give me a one-sentence summary.']) {
const reply = await handle.executeUpdate(chatTurn, { args: [prompt] });
console.log(`user: ${prompt}`);
console.log(`assistant: ${reply}\n`);
}

await handle.signal(chatEnd);
await handle.result();
}

run().catch((err) => {
console.error(err);
process.exit(1);
});
21 changes: 21 additions & 0 deletions strands-agents/src/hello-world.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Client, Connection } from '@temporalio/client';
import { helloWorld } from './workflows';

async function run() {
const connection = await Connection.connect({
address: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
});
const client = new Client({ connection });

const result = await client.workflow.execute(helloWorld, {
args: ['Write a haiku about durable execution.'],
taskQueue: 'strands-agents',
workflowId: 'strands-hello-world',
});
console.log(`Result: ${result}`);
}

run().catch((err) => {
console.error(err);
process.exit(1);
});
21 changes: 21 additions & 0 deletions strands-agents/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Client, Connection } from '@temporalio/client';
import { hooksWorkflow } from './workflows';

async function run() {
const connection = await Connection.connect({
address: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
});
const client = new Client({ connection });

const fired = await client.workflow.execute(hooksWorkflow, {
args: ["Echo 'hello' once."],
taskQueue: 'strands-agents',
workflowId: 'strands-hooks',
});
console.log(`Tools that fired AfterToolCallEvent: ${JSON.stringify(fired)}`);
}

run().catch((err) => {
console.error(err);
process.exit(1);
});
32 changes: 32 additions & 0 deletions strands-agents/src/human-in-the-loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Client, Connection } from '@temporalio/client';
import { hitlApproveSignal, hitlPendingApprovalQuery, humanInTheLoop } from './workflows';

async function run() {
const connection = await Connection.connect({
address: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
});
const client = new Client({ connection });

const handle = await client.workflow.start(humanInTheLoop, {
args: ['Please delete /tmp/sensitive.txt'],
taskQueue: 'strands-agents',
workflowId: 'strands-human-in-the-loop',
});

let reason: string | null = null;
while (reason === null) {
await new Promise((resolve) => setTimeout(resolve, 500));
reason = await handle.query(hitlPendingApprovalQuery);
}
console.log(`Approval requested: ${reason}`);

await handle.signal(hitlApproveSignal, 'approve');

const result = await handle.result();
console.log(`Result: ${result}`);
}

run().catch((err) => {
console.error(err);
process.exit(1);
});
Loading
Loading