From 9e13bba5bbfb2dea7d69ee39d617f8ba529cdeaf Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 28 Aug 2026 17:40:37 +0000 Subject: [PATCH 1/4] feat(templates): add strands-ts typescript runtime template --- .../strands-http-typescript/README.md | 36 +++++ .../gitignore.template | 22 +++ .../templates/strands-http-typescript/main.ts | 149 ++++++++++++++++++ .../mcp_client/client.ts | 11 ++ .../strands-http-typescript/memory/memory.ts | 52 ++++++ .../strands-http-typescript/model/load.ts | 102 ++++++++++++ .../strands-http-typescript/package.json | 29 ++++ .../strands-http-typescript/tsconfig.json | 19 +++ 8 files changed, 420 insertions(+) create mode 100644 src/assets/templates/strands-http-typescript/README.md create mode 100644 src/assets/templates/strands-http-typescript/gitignore.template create mode 100644 src/assets/templates/strands-http-typescript/main.ts create mode 100644 src/assets/templates/strands-http-typescript/mcp_client/client.ts create mode 100644 src/assets/templates/strands-http-typescript/memory/memory.ts create mode 100644 src/assets/templates/strands-http-typescript/model/load.ts create mode 100644 src/assets/templates/strands-http-typescript/package.json create mode 100644 src/assets/templates/strands-http-typescript/tsconfig.json diff --git a/src/assets/templates/strands-http-typescript/README.md b/src/assets/templates/strands-http-typescript/README.md new file mode 100644 index 000000000..08f22a6fd --- /dev/null +++ b/src/assets/templates/strands-http-typescript/README.md @@ -0,0 +1,36 @@ +This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a `.gitignore` file, an +`agentcore/` folder which represents the configurations and state associated with this project. Other `agentcore` +commands like `deploy`, `dev`, and `invoke` rely on the configuration stored here. + +## Agent Root + +The main entrypoint to your app is defined in `main.ts`. Using the AgentCore SDK `BedrockAgentCoreApp`, this file +defines an HTTP server that streams tokens from your chosen Agent framework SDK. + +`model/load.ts` instantiates your chosen model provider. + +## Input Validation + +The generated Zod request schema keeps plain prompts typed as strings before forwarding them to Strands. Retain this +validation when extending the request shape, and pass only prompt text to the agent. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| `{{identityProviders.[0].envVarName}}` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| `LOCAL_DEV` | No | Set to `1` to use `.env.local` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, `node_modules/` is already populated with dependencies. + +`agentcore project dev` will start a local server using `tsx watch main.ts` for hot reload on 0.0.0.0:8080. + +# Deployment + +After providing credentials, `agentcore project deploy` will deploy your project into Amazon Bedrock AgentCore. diff --git a/src/assets/templates/strands-http-typescript/gitignore.template b/src/assets/templates/strands-http-typescript/gitignore.template new file mode 100644 index 000000000..feb4f544d --- /dev/null +++ b/src/assets/templates/strands-http-typescript/gitignore.template @@ -0,0 +1,22 @@ +# Environment variables +.env +.env.* + +# Node +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/src/assets/templates/strands-http-typescript/main.ts b/src/assets/templates/strands-http-typescript/main.ts new file mode 100644 index 000000000..9722285e5 --- /dev/null +++ b/src/assets/templates/strands-http-typescript/main.ts @@ -0,0 +1,149 @@ +import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime'; +import { Agent, McpClient, tool, type ToolList } from '@strands-agents/sdk'; +import { z } from 'zod'; +import { loadModel } from './model/load.js'; +import { getStreamableHttpMcpClient } from './mcp_client/client.js'; +{{#if hasMemory}} +import { getActorId, getOrCreateMemoryManager } from './memory/memory.js'; +{{/if}} + +// Define a collection of MCP clients (filter out anything that failed to initialize) +const mcpClients: McpClient[] = [getStreamableHttpMcpClient()].filter( + (client): client is McpClient => Boolean(client) +); + +// Define a collection of tools used by the model +const tools: ToolList = []; + +// Define a simple function tool — the Zod schema gives us type inference and runtime validation for free +const addNumbers = tool({ + name: 'add_numbers', + description: 'Return the sum of two numbers', + inputSchema: z.object({ + a: z.number(), + b: z.number(), + }), + callback: async ({ a, b }) => a + b, +}); +tools.push(addNumbers); + +// Add MCP clients to tools +tools.push(...mcpClients); + +const SYSTEM_PROMPT = ` +You are a helpful assistant. Use tools when appropriate. +`; + +const requestSchema = z.object({ + prompt: z.string().default(''), +}); + +{{#if hasMemory}} +const agentCache = new Map(); + +async function getOrCreateAgent(sessionId: string, actorId: string): Promise { + const key = `${actorId}:${sessionId}`; + let agent = agentCache.get(key); + if (agent) return agent; + + const model = await loadModel(); + agent = new Agent({ + model, + systemPrompt: SYSTEM_PROMPT, + tools, + memoryManager: getOrCreateMemoryManager(sessionId, actorId) ?? undefined, + }); + agentCache.set(key, agent); + return agent; +} +{{else}} +const AGENT_CACHE_LIMIT = 128; + +// Reuses one Agent per sessionId so each session keeps its own in-process +// conversation history (best-effort; resets on cold start). A Map preserves +// insertion order, so it doubles as an LRU bounded to 128 sessions — a local +// dev process serving many sessions cannot leak history between them or grow +// without bound. On AgentCore Runtime each microVM serves a single session, so +// this holds one entry. For durable history, attach memory. +const agentCache = new Map(); + +async function getOrCreateAgent(sessionId: string): Promise { + const existing = agentCache.get(sessionId); + if (existing) { + agentCache.delete(sessionId); + agentCache.set(sessionId, existing); + return existing; + } + if (agentCache.size >= AGENT_CACHE_LIMIT) { + const oldest = agentCache.keys().next().value; + if (oldest !== undefined) agentCache.delete(oldest); + } + const model = await loadModel(); + const agent = new Agent({ + model, + systemPrompt: SYSTEM_PROMPT, + tools, + }); + agentCache.set(sessionId, agent); + return agent; +} +{{/if}} + +const app = new BedrockAgentCoreApp({ + invocationHandler: { + requestSchema, + async *process(payload, context) { + {{#if hasMemory}} + const sessionId = context?.sessionId ?? 'default-session'; + const actorId = getActorId(payload, context); + const agent = await getOrCreateAgent(sessionId, actorId); + {{else}} + const sessionId = context?.sessionId ?? 'default-session'; + const agent = await getOrCreateAgent(sessionId); + {{/if}} + + {{#if hasMemory}} + try { + for await (const event of agent.stream(payload.prompt)) { + if ( + event.type === 'modelStreamUpdateEvent' && + event.event?.type === 'modelContentBlockDeltaEvent' && + event.event.delta?.type === 'textDelta' + ) { + yield { data: event.event.delta.text }; + } + } + } finally { + // Drain in-flight createEvent calls before the runtime can reclaim + // the session microVM. flush() is the durability mechanism — without + // it, an idle reclamation can lose the tail of the conversation. + await agent.memoryManager?.flush(); + } + {{else}} + // Snapshot history before streaming so a failed turn can be rolled back. + // Agent.stream() appends the user message before invoking the model; on a + // mid-stream error that user turn would otherwise linger in the cached + // agent, and the next turn for this session would send consecutive user + // messages (rejected by providers that require strict role alternation, + // e.g. Anthropic). Restoring on error keeps the session reusable. + const snapshot = agent.takeSnapshot({ include: ['messages'] }); + try { + for await (const event of agent.stream(payload.prompt)) { + if ( + event.type === 'modelStreamUpdateEvent' && + event.event?.type === 'modelContentBlockDeltaEvent' && + event.event.delta?.type === 'textDelta' + ) { + yield { data: event.event.delta.text }; + } + } + } catch (error) { + agent.loadSnapshot(snapshot); + throw error; + } + {{/if}} + }, + }, +}); + +app.run({ port: parseInt(process.env.PORT ?? '8080') }); diff --git a/src/assets/templates/strands-http-typescript/mcp_client/client.ts b/src/assets/templates/strands-http-typescript/mcp_client/client.ts new file mode 100644 index 000000000..d6e8528f8 --- /dev/null +++ b/src/assets/templates/strands-http-typescript/mcp_client/client.ts @@ -0,0 +1,11 @@ +import { McpClient } from '@strands-agents/sdk'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +// ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication +const EXAMPLE_MCP_ENDPOINT = 'https://mcp.exa.ai/mcp'; + +export function getStreamableHttpMcpClient(): McpClient { + // to use an MCP server that supports bearer authentication, add a headers() callback to requestInit + const transport = new StreamableHTTPClientTransport(new URL(EXAMPLE_MCP_ENDPOINT)); + return new McpClient({ transport }); +} diff --git a/src/assets/templates/strands-http-typescript/memory/memory.ts b/src/assets/templates/strands-http-typescript/memory/memory.ts new file mode 100644 index 000000000..0874fb4db --- /dev/null +++ b/src/assets/templates/strands-http-typescript/memory/memory.ts @@ -0,0 +1,52 @@ +import { randomUUID } from 'node:crypto'; +import { MemoryManager } from '@strands-agents/sdk'; +import { createAgentCoreMemoryStores } from 'bedrock-agentcore/experimental/memory/strands'; + +const MEMORY_ID = process.env.{{memoryEnvVarName}}; + +const CUSTOM_ACTOR_ID_HEADER = 'x-amzn-bedrock-agentcore-runtime-custom-actor-id'; + +export function getActorId(payload: any, context: any): string { + const raw = + context?.headers?.[CUSTOM_ACTOR_ID_HEADER] || + payload?.userId || + context?.sessionId; + return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : randomUUID(); +} + +const memoryManagerCache = new Map(); + +export function getOrCreateMemoryManager(sessionId: string, actorId: string): MemoryManager | null { + if (!MEMORY_ID) return null; + + const key = `${actorId}:${sessionId}`; + let manager = memoryManagerCache.get(key); + if (manager) return manager; + + const stores = createAgentCoreMemoryStores({ + memoryId: MEMORY_ID, + actorId, + sessionId, + namespaces: [ +{{#if (includes memoryStrategies "SEMANTIC")}} + { namespace: '/users/{actorId}/facts' }, +{{/if}} +{{#if (includes memoryStrategies "USER_PREFERENCE")}} + { namespace: '/users/{actorId}/preferences' }, +{{/if}} +{{#if (includes memoryStrategies "EPISODIC")}} + { namespace: '/episodes/{actorId}/{sessionId}' }, +{{/if}} +{{#if (includes memoryStrategies "SUMMARIZATION")}} + { namespace: '/summaries/{actorId}/{sessionId}' }, +{{/if}} + ], + // readMode defaults to 'per-namespace' (one retrieve call per namespace). + // Switch to 'subtree' to consolidate to a single hierarchical recall call. + extraction: true, + }); + + manager = new MemoryManager({ stores }); + memoryManagerCache.set(key, manager); + return manager; +} diff --git a/src/assets/templates/strands-http-typescript/model/load.ts b/src/assets/templates/strands-http-typescript/model/load.ts new file mode 100644 index 000000000..00e22cd9b --- /dev/null +++ b/src/assets/templates/strands-http-typescript/model/load.ts @@ -0,0 +1,102 @@ +{{#if (eq modelProvider "Bedrock")}} +import { BedrockModel } from '@strands-agents/sdk/models/bedrock'; + +export function loadModel(): BedrockModel { + return new BedrockModel({ modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0' }); +} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import { AnthropicModel } from '@strands-agents/sdk/models/anthropic'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} or ANTHROPIC_API_KEY not found. Add your key to agentcore/.env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _model: AnthropicModel | undefined; + +export async function loadModel(): Promise { + if (!_model) { + const apiKey = await getApiKey(); + _model = new AnthropicModel({ + apiKey, + modelId: 'claude-sonnet-4-5-20250929', + maxTokens: 5000, + }); + } + return _model; +} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import { OpenAIModel } from '@strands-agents/sdk/models/openai'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} or OPENAI_API_KEY not found. Add your key to agentcore/.env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _model: OpenAIModel | undefined; + +export async function loadModel(): Promise { + if (!_model) { + const apiKey = await getApiKey(); + _model = new OpenAIModel({ + api: 'chat', + apiKey, + modelId: 'gpt-4.1', + }); + } + return _model; +} +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import { GoogleModel } from '@strands-agents/sdk/models/google'; +import { withApiKey } from 'bedrock-agentcore/identity'; + +const IDENTITY_PROVIDER_NAME = '{{identityProviders.[0].name}}'; +const IDENTITY_ENV_VAR = '{{identityProviders.[0].envVarName}}'; + +async function getApiKey(): Promise { + if (process.env.LOCAL_DEV === '1') { + const apiKey = process.env[IDENTITY_ENV_VAR] ?? process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error(`${IDENTITY_ENV_VAR} or GEMINI_API_KEY not found. Add your key to agentcore/.env.local`); + } + return apiKey; + } + return withApiKey({ providerName: IDENTITY_PROVIDER_NAME })(async (apiKey: string) => apiKey)(); +} + +let _model: GoogleModel | undefined; + +export async function loadModel(): Promise { + if (!_model) { + const apiKey = await getApiKey(); + _model = new GoogleModel({ + apiKey, + modelId: 'gemini-2.5-flash', + }); + } + return _model; +} +{{/if}} diff --git a/src/assets/templates/strands-http-typescript/package.json b/src/assets/templates/strands-http-typescript/package.json new file mode 100644 index 000000000..77eb3bc8e --- /dev/null +++ b/src/assets/templates/strands-http-typescript/package.json @@ -0,0 +1,29 @@ +{ + "name": "{{name}}", + "version": "0.1.0", + "description": "AgentCore Runtime Application using Strands TypeScript SDK", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch main.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "~1.25.2", + "@opentelemetry/api": "~1.9.0", + "@strands-agents/sdk": "~1.5.0", + "bedrock-agentcore": "~0.3.0", + "tsx": "~4.19.0", + "zod": "~4.4.3" + }, + "devDependencies": { + "@types/node": "~22.0.0", + "typescript": "~5.6.0" + }, + "overrides": { + "bedrock-agentcore": { + "@strands-agents/sdk": "$@strands-agents/sdk" + } + } +} diff --git a/src/assets/templates/strands-http-typescript/tsconfig.json b/src/assets/templates/strands-http-typescript/tsconfig.json new file mode 100644 index 000000000..c199ae076 --- /dev/null +++ b/src/assets/templates/strands-http-typescript/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} From f3a7668ab94eed9f506ce5a278c64d6bce5bc4b4 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 28 Aug 2026 17:40:43 +0000 Subject: [PATCH 2/4] feat(templates): wire the strands-ts resolver and derive entrypoint from language --- .../__snapshots__/manager.test.ts.snap | 75 +++++++++++++++++++ src/core/project/manager.test.ts | 17 +++++ src/core/project/templates/runtime.ts | 58 +++++++++++++- src/handlers/project/shortcuts.ts | 17 +++-- src/handlers/project/types.ts | 5 +- 5 files changed, 162 insertions(+), 10 deletions(-) diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap index 2a538c1bb..5c4089830 100644 --- a/src/core/project/__snapshots__/manager.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -101,3 +101,78 @@ exports[`FsProjectManager.create snapshots the Strands project manifest and runt ], } `; + +exports[`FsProjectManager.create snapshots the Strands TypeScript project manifest and runtime spec 1`] = ` +{ + "manifest": [ + ".gitignore", + "agentcore/.env.local", + "agentcore/agentcore.json", + "agentcore/aws-targets.json", + "agentcore/cdk/.gitignore", + "agentcore/cdk/.npmignore", + "agentcore/cdk/.prettierrc", + "agentcore/cdk/README.md", + "agentcore/cdk/bin/cdk.ts", + "agentcore/cdk/cdk.json", + "agentcore/cdk/jest.config.js", + "agentcore/cdk/lib/cdk-stack.ts", + "agentcore/cdk/package.json", + "agentcore/cdk/test/cdk.test.ts", + "agentcore/cdk/tsconfig.json", + "app/strands_agent/.gitignore", + "app/strands_agent/README.md", + "app/strands_agent/main.ts", + "app/strands_agent/mcp_client/client.ts", + "app/strands_agent/memory/memory.ts", + "app/strands_agent/model/load.ts", + "app/strands_agent/package.json", + "app/strands_agent/tsconfig.json", + ], + "memories": [ + { + "eventExpiryDuration": 30, + "name": "strands_agentMemory", + "strategies": [ + { + "namespaceTemplates": [ + "/users/{actorId}/facts", + ], + "type": "SEMANTIC", + }, + { + "namespaceTemplates": [ + "/users/{actorId}/preferences", + ], + "type": "USER_PREFERENCE", + }, + { + "namespaceTemplates": [ + "/summaries/{actorId}/{sessionId}", + ], + "type": "SUMMARIZATION", + }, + { + "namespaceTemplates": [ + "/episodes/{actorId}/{sessionId}", + ], + "reflectionNamespaceTemplates": [ + "/episodes/{actorId}", + ], + "type": "EPISODIC", + }, + ], + }, + ], + "runtimes": [ + { + "build": "CodeZip", + "codeLocation": "app/strands_agent", + "entrypoint": "main.js", + "name": "strands_agent", + "protocol": "HTTP", + "runtimeVersion": "NODE_22", + }, + ], +} +`; diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 89c435159..635f9b5cf 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -20,6 +20,7 @@ import type { DeployBackendInput, ProjectBackend } from "./backends/types"; const HELLO_WORLD_PYTHON = resolveRuntimeTemplateShortcut("hello-world-python"); const HELLO_WORLD_PYTHON_CONTAINER = resolveRuntimeTemplateShortcut("hello-world-python-container"); const STRANDS_PYTHON = resolveRuntimeTemplateShortcut("strands-python"); +const STRANDS_TS = resolveRuntimeTemplateShortcut("strands-ts"); const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -106,6 +107,22 @@ describe("FsProjectManager.create", () => { }).toMatchSnapshot(); }); + test("snapshots the Strands TypeScript project manifest and runtime spec", async () => { + const directory = await inTempDirectory(); + await runCreate(manager().manager, { + name: "example", + scaffoldRuntimeInput: STRANDS_TS, + }); + + const projectRoot = join(directory, "example"); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect({ + manifest: await projectManifest(projectRoot), + runtimes: spec.runtimes, + memories: spec.memories, + }).toMatchSnapshot(); + }); + test("writes a deploy-ready agentcore.json registering the template agent", async () => { const directory = await inTempDirectory(); await runCreate(manager().manager, { diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 6a15566d1..62d83a66a 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -6,12 +6,20 @@ import type { TemplateRenderer, TemplateResolver } from "./types"; import type { ScaffoldRuntimeInput } from "../../../handlers/project/types"; import { InputValidationError } from "../../../errors"; +/** + * The scaffolded entrypoint filename for a language. TypeScript deploys a + * compiled main.js (esbuild runs at synth), while Python runs main.py directly. + */ +function entrypointForLanguage(language: ScaffoldRuntimeInput["language"]): string { + return language === "TypeScript" ? "main.js" : "main.py"; +} + function buildRuntimeSpec(input: RuntimeResourceConfig): ProjectRuntime { const { scaffoldRuntimeInput, name, ...infra } = input; return { name, build: scaffoldRuntimeInput.build, - entrypoint: scaffoldRuntimeInput.entrypoint, + entrypoint: entrypointForLanguage(scaffoldRuntimeInput.language), codeLocation: `app/${name}` as ProjectRuntime["codeLocation"], ...(scaffoldRuntimeInput.runtimeVersion && { runtimeVersion: scaffoldRuntimeInput.runtimeVersion, @@ -49,6 +57,19 @@ function toPythonPackageName(name: string): string { .replace(/[^a-zA-Z0-9]+$/, ""); } +/** + * Normalize a name for use as an npm package name: lowercase, URL-safe, and + * trimmed of leading/trailing separators (npm rejects uppercase and names that + * start with a dot or underscore). + */ +function toNpmPackageName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-") + .replace(/^[._-]+/, "") + .replace(/[._-]+$/, ""); +} + function buildResolverKey( framework: ScaffoldRuntimeInput["framework"], language: ScaffoldRuntimeInput["language"], @@ -133,6 +154,41 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa }, }; }, + [buildResolverKey("strands", "TypeScript")]: async (input: RuntimeResourceConfig) => { + if (input.protocol !== undefined && input.protocol !== "HTTP") + throw new InputValidationError("the strands-ts template only supports HTTP"); + + if (input.scaffoldRuntimeInput.build !== "CodeZip") + throw new InputValidationError("the strands template only supports CodeZip builds"); + + const memory = input.scaffoldRuntimeInput.memory; + const context = { + name: toNpmPackageName(input.name), + modelProvider: input.scaffoldRuntimeInput.modelProvider, + hasMemory: memory !== undefined, + // the CDK injects this env var corresponding to the actual ID once its resolved on deployment. + memoryEnvVarName: memory ? `MEMORY_${memory.name.toUpperCase()}_ID` : undefined, + memoryStrategies: memory?.strategies.map(({ type }) => type) ?? [], + hasIdentity: false, + identityProviders: [], + }; + const tree = await FsTreeNode.fromAssetSource( + { assetSource }, + { assetDir: "templates/strands-http-typescript" }, + { + rootDirName: input.name, + transformContent: (raw) => templateRenderer.render(raw, context), + filter: (name, isDir) => memory !== undefined || !isDir || name !== "memory", + }, + ); + return { + tree, + spec: { + runtimes: [{ ...buildRuntimeSpec(input), protocol: "HTTP" as const }], + ...(memory && { memories: [memory] }), + }, + }; + }, }); type GetRuntimeTemplateResolverConfig = { diff --git a/src/handlers/project/shortcuts.ts b/src/handlers/project/shortcuts.ts index 81e81a77b..78842e444 100644 --- a/src/handlers/project/shortcuts.ts +++ b/src/handlers/project/shortcuts.ts @@ -48,7 +48,6 @@ export const RUNTIME_TEMPLATE_SHORTCUTS = { framework: "none", modelProvider: "Bedrock", memory: "none", - entrypoint: "main.py", runtimeVersion: "PYTHON_3_14", }, "hello-world-python-container": { @@ -58,7 +57,6 @@ export const RUNTIME_TEMPLATE_SHORTCUTS = { framework: "none", modelProvider: "Bedrock", memory: "none", - entrypoint: "main.py", }, "strands-python": { runtimeName: "strands_agent", @@ -67,9 +65,17 @@ export const RUNTIME_TEMPLATE_SHORTCUTS = { framework: "strands", modelProvider: "Bedrock", memory: "longAndShortTerm", - entrypoint: "main.py", runtimeVersion: "PYTHON_3_14", }, + "strands-ts": { + runtimeName: "strands_agent", + build: "CodeZip", + language: "TypeScript", + framework: "strands", + modelProvider: "Bedrock", + memory: "longAndShortTerm", + runtimeVersion: "NODE_22", + }, } as const satisfies Record; export type RuntimeTemplateShortcutName = keyof typeof RUNTIME_TEMPLATE_SHORTCUTS; @@ -90,7 +96,7 @@ export function resolveRuntimeTemplateShortcut( name: RuntimeTemplateShortcutName, overrides?: RuntimeTemplateOverrides, ): ScaffoldRuntimeInput { - const template = RUNTIME_TEMPLATE_SHORTCUTS[name]; + const template: RuntimeTemplateShortcut = RUNTIME_TEMPLATE_SHORTCUTS[name]; const runtimeName = overrides?.runtimeName ?? template.runtimeName; const build = overrides?.build ?? template.build; const memoryShortcutName = overrides?.memory ?? template.memory; @@ -104,8 +110,7 @@ export function resolveRuntimeTemplateShortcut( modelProvider: overrides?.modelProvider ?? template.modelProvider, ...(overrides?.apiKey !== undefined && { apiKey: overrides.apiKey }), ...(memory && { memory }), - entrypoint: template.entrypoint, - runtimeVersion: build === "CodeZip" ? "PYTHON_3_14" : undefined, + runtimeVersion: build === "CodeZip" ? (template.runtimeVersion ?? "PYTHON_3_14") : undefined, }; const result = ScaffoldRuntimeInputSchema.safeParse(input); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 19b3a8ef4..d56831361 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -7,7 +7,7 @@ import type { ProjectSpecSchema } from "../../projectSchemas/project"; import z from "zod"; import type { RuntimeResourceConfig } from "./add/runtime/types"; import type { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config"; -import { AgentNameSchema, BuildTypeSchema, EntrypointSchema } from "../../projectSchemas/runtime"; +import { AgentNameSchema, BuildTypeSchema } from "../../projectSchemas/runtime"; import { RuntimeVersionSchema } from "../../projectSchemas/constants"; import type { AgentCoreGateway, AgentCoreGatewayTarget } from "../../projectSchemas/gateway"; import type { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy"; @@ -26,12 +26,11 @@ export const ScaffoldRuntimeInputSchema = z .object({ runtimeName: AgentNameSchema, build: BuildTypeSchema, - language: z.enum(["Python"]), + language: z.enum(["Python", "TypeScript"]), framework: z.enum(["strands", "none"]), modelProvider: z.enum(["Bedrock"]), apiKey: z.string().min(1).optional(), memory: MemorySchema.optional(), - entrypoint: EntrypointSchema, runtimeVersion: RuntimeVersionSchema.optional(), }) .refine(({ modelProvider, apiKey }) => !(modelProvider === "Bedrock" && apiKey !== undefined), { From 7af252c07756d3299e5eacee9216bd3729cf36be Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 28 Aug 2026 17:40:49 +0000 Subject: [PATCH 3/4] feat(project): accept the TypeScript language in create and add-runtime --- .../project/add/runtime/index.test.ts | 56 +++++++++++++++++++ src/handlers/project/add/runtime/index.ts | 10 +++- src/handlers/project/create/index.ts | 10 +++- src/handlers/project/project.test.ts | 37 ++++++++++++ 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index e5c2f047f..dfd42baa7 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -365,6 +365,47 @@ describe("project add runtime", () => { expect(memory.strategies.map(({ type }: { type: string }) => type)).toEqual(expectedStrategies); }); + test.each<[string, string[], string[]]>([ + [ + "template preset", + ["--name", "my_agent", "--template", "strands-ts"], + ["SEMANTIC", "USER_PREFERENCE", "SUMMARIZATION", "EPISODIC"], + ], + [ + "custom without memory", + [ + "--name", + "my_agent", + "--build", + "CodeZip", + "--language", + "TypeScript", + "--framework", + "strands", + "--model-provider", + "Bedrock", + "--memory", + "none", + ], + [], + ], + ])("strands-ts %s scaffolds a TypeScript agent", async (_label, flags, expectedStrategies) => { + const projectRoot = await inProject(); + await run(["add", "runtime", ...flags]); + + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + const runtime = spec.runtimes.find( + (candidate: { name: string }) => candidate.name === "my_agent", + ); + expect(runtime).toMatchObject({ entrypoint: "main.js", runtimeVersion: "NODE_22" }); + + const memory = (spec.memories ?? []).find( + (candidate: { name: string }) => candidate.name === "my_agentMemory", + ); + const strategies = memory?.strategies.map(({ type }: { type: string }) => type) ?? []; + expect(strategies).toEqual(expectedStrategies); + }); + test.each<[string, string[]]>([ ["missing --name", ["--template", "hello-world-python"]], [ @@ -390,6 +431,21 @@ describe("project add runtime", () => { "strands-python only supports CodeZip builds", ["--name", "my_agent", "--template", "strands-python", "--build", "Container"], ], + [ + "TypeScript without a strands template has no resolver", + [ + "--name", + "my_agent", + "--build", + "CodeZip", + "--language", + "TypeScript", + "--framework", + "none", + "--model-provider", + "Bedrock", + ], + ], [ "invalid JSON in --network-config", ["--name", "my_agent", ...template, "--network-config", "{bad}"], diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 4a753a59a..eb966836f 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -32,7 +32,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flag( "language", "target language for the scaffolded runtime code", - z.enum(["Python"]).optional(), + z.enum(["Python", "TypeScript"]).optional(), ), flag( "framework", @@ -145,8 +145,12 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => modelProvider: flags["model-provider"], apiKey, memory: MEMORY_SHORTCUTS[flags.memory ?? defaultMemory](runtimeName), - entrypoint: "main.py", - runtimeVersion: flags.build === "CodeZip" ? "PYTHON_3_14" : undefined, + runtimeVersion: + flags.build === "CodeZip" + ? flags.language === "TypeScript" + ? "NODE_22" + : "PYTHON_3_14" + : undefined, }) : resolveRuntimeTemplateShortcut("hello-world-python", { runtimeName: flags.name }); diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index 69441eff5..ea542e8b2 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -40,7 +40,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = flag( "language", "target language for the scaffolded runtime code", - z.enum(["Python"]).optional(), + z.enum(["Python", "TypeScript"]).optional(), ), flag( "framework", @@ -116,8 +116,12 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = modelProvider: flags["model-provider"], apiKey, memory: MEMORY_SHORTCUTS[flags["memory"] ?? defaultMemory](runtimeName), - entrypoint: "main.py", - runtimeVersion: flags["build"] === "CodeZip" ? "PYTHON_3_14" : undefined, + runtimeVersion: + flags["build"] === "CodeZip" + ? flags["language"] === "TypeScript" + ? "NODE_22" + : "PYTHON_3_14" + : undefined, }) : resolveRuntimeTemplateShortcut("hello-world-python"); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 742a4965c..5983e71d8 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -241,6 +241,43 @@ describe("project create", () => { ]); }); + test("scaffolds a TypeScript strands runtime from custom flags", async () => { + const directory = await inTempDirectory(); + await run([ + "create", + "--name", + "MyAgent", + "--build", + "CodeZip", + "--language", + "TypeScript", + "--framework", + "strands", + "--model-provider", + "Bedrock", + "--memory", + "none", + "--skip-install", + "--skip-git", + ]); + + const projectRoot = join(directory, "MyAgent"); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + // NODE_22 runtimes deploy a compiled main.js, so the spec entrypoint is main.js + // even though the scaffolded source is main.ts. + expect(spec.runtimes).toEqual([ + { + name: "MyAgent", + build: "CodeZip", + entrypoint: "main.js", + codeLocation: "app/MyAgent", + runtimeVersion: "NODE_22", + protocol: "HTTP", + }, + ]); + expect(await Bun.file(join(projectRoot, "app", "MyAgent", "main.ts")).exists()).toBe(true); + }); + test.each(["shortTerm", "longAndShortTerm"] as const)( "rejects --memory %s with --framework none", async (memoryShortcut) => { From 18d284c8af2a76e1a7171107350a63eb0c283b08 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 28 Aug 2026 17:40:49 +0000 Subject: [PATCH 4/4] feat(dev): install and run typescript runtimes locally --- src/core/dev/codezip.test.ts | 12 ++++++++++++ src/core/dev/codezip.ts | 15 +++++++++++---- src/core/project/manager.tsx | 4 ++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index 4b5af17d8..585889d9f 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -203,6 +203,18 @@ describe("CodeZipDevRunner", () => { ["npm", "exec", "--", "tsx", "watch", "index.js"], ]); }); + + test("runs the .ts source when a TypeScript runtime's entrypoint is the compiled .js", async () => { + const root = await projectRoot(true); + await writeFile(join(root, "app", "hello-world", "main.ts"), ""); + const { calls, runner } = harness(); + + await collect(runner.run(input(root, runtime({ entrypoint: "main.js" })))); + + expect(calls.map(({ command }) => command)).toEqual([ + ["npm", "exec", "--", "tsx", "watch", "main.ts"], + ]); + }); }); describe("CodeZipDevRunner OTEL instrumentation", () => { diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index 9764f4f3f..08f1386d6 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -35,13 +35,20 @@ export class CodeZipDevRunner implements DevRunner { resolvePathWithinProject(input.projectRoot, directory, "runtime code directory"); const [entrypoint] = input.runtime.entrypoint.split(":"); - const entrypointPath = resolve(directory, entrypoint!); + // the spec stores `.js` to be compatible with deploy, but dev uses `tsx watch` on the source code. + // therefore we take the `.ts` version of the entrypoint if it exists for dev, and fallback to the + // `.js` in case a project has a pure js entrypoint. + const devEntrypoint = + entrypoint!.endsWith(".js") && isFile(resolve(directory, entrypoint!.replace(/\.js$/, ".ts"))) + ? entrypoint!.replace(/\.js$/, ".ts") + : entrypoint!; + const entrypointPath = resolve(directory, devEntrypoint); if (!isFile(entrypointPath)) { throw new InputValidationError(`runtime entrypoint not found: ${entrypointPath}`); } resolvePathWithinProject(input.projectRoot, entrypointPath, "runtime entrypoint"); - if (!entrypoint!.endsWith(".py") && !existsSync(join(directory, "node_modules"))) { + if (!devEntrypoint.endsWith(".py") && !existsSync(join(directory, "node_modules"))) { yield { type: "status", message: "Installing Node dependencies with npm" }; yield* this.streamProcess(["npm", "install"], { cwd: directory, @@ -51,8 +58,8 @@ export class CodeZipDevRunner implements DevRunner { } yield { type: "status", message: "Starting development server" }; - const serverProcess = commandForRuntime(entrypoint!, directory, input); - if (entrypoint!.endsWith(".py") && input.env?.OTEL_EXPORTER_OTLP_ENDPOINT) { + const serverProcess = commandForRuntime(devEntrypoint, directory, input); + if (devEntrypoint.endsWith(".py") && input.env?.OTEL_EXPORTER_OTLP_ENDPOINT) { const sitecustomizeDir = await this.findOtelSitecustomizeDir(directory, input.signal); if (sitecustomizeDir) { const existing = serverProcess.options.env?.PYTHONPATH; diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 9ede69c1f..963c1be9a 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -508,6 +508,10 @@ export class FsProjectManager implements ProjectManager { ); yield { message: "Syncing Python dependencies with uv" }; await this.run(["uv", "sync"], appDir); + } else if (existsSync(join(appDir, "package.json"))) { + await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); + yield { message: "Installing Node dependencies with npm" }; + await this.run(["npm", "install"], appDir); } }