From dcc8f7d6161daf177aab1e926b91b4bdad4c4505 Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Wed, 14 Oct 2026 10:10:56 +0200 Subject: [PATCH 1/3] docs(prompt-management): add a guide for prompts in Temporal workflows --- contents/docs/prompt-management/temporal.mdx | 216 +++++++++++++++++++ src/navs/index.js | 4 + 2 files changed, 220 insertions(+) create mode 100644 contents/docs/prompt-management/temporal.mdx diff --git a/contents/docs/prompt-management/temporal.mdx b/contents/docs/prompt-management/temporal.mdx new file mode 100644 index 000000000000..a81e49b258ca --- /dev/null +++ b/contents/docs/prompt-management/temporal.mdx @@ -0,0 +1,216 @@ +--- +title: Using prompts in Temporal workflows +sidebar: Docs +showTitle: true +availability: +free: none +selfServe: full +enterprise: full +--- + +Temporal splits your code into workflows, which must be deterministic, and activities, which can do anything. A prompt fetch is a network call that can return a different version each time, so it belongs in an activity. This page covers that and the other things a long-running worker needs: one client per process, a warm cache, a record of which version each activity used, and a clean shutdown. + +## Fetch prompts in activities, not workflows + +Fetch the prompt inside the activity that uses it. Do not import the PostHog SDK from a workflow file: + +- In Python, the Temporal sandbox blocks threads, sockets, and `urllib` inside workflow code, and the SDK uses all three when it loads. +- In TypeScript, workflow code runs in an isolated V8 context without Node APIs, so a workflow file that imports `@posthog/ai` fails to bundle. + +If a workflow file needs a type from the SDK, put the import inside `with workflow.unsafe.imports_passed_through():` (Python) or use a type-only import (TypeScript). + +## Create one client per worker + +A worker process runs for days. Create the PostHog client and the `Prompts` client once, at module level in your activities file, and reuse them from every activity. Each `Prompts` instance has its own cache, so a client created inside the activity starts cold on every run. + + + +```python +# activities.py +import asyncio +from temporalio import activity +from posthog import Posthog +from posthog.ai import Prompts +from posthog.ai.openai import AsyncOpenAI + +posthog = Posthog( + '', + host='https://us.posthog.com', + secret_key='' +) +prompts = Prompts(posthog) +openai = AsyncOpenAI(posthog_client=posthog, api_key='') +``` + +```typescript +// activities.ts +import { Context } from '@temporalio/activity' +import { PostHog } from 'posthog-node' +import { Prompts } from '@posthog/ai' +import { OpenAI } from '@posthog/ai/openai' + +const posthog = new PostHog('', { + host: 'https://us.posthog.com', + personalApiKey: '', +}) +const prompts = new Prompts({ posthog }) +const openai = new OpenAI({ apiKey: '', posthog }) +``` + + + +## Warm the cache when the worker starts + +The SDK fetches a prompt the first time an activity asks for it, and again when its cache entry expires. Load every prompt in one request before the worker starts polling, so the first activities do not each wait on the network. Fetch at a label, such as `production`, so a label move reaches the worker on the next cache cycle without a deploy. + + + +```python +# worker.py +from temporalio.client import Client +from temporalio.worker import Worker +from activities import draft_reply, prompts + +async def main(): + prompts.get_all(label='production') + + client = await Client.connect('localhost:7233') + worker = Worker(client, task_queue='support', workflows=[...], activities=[draft_reply]) + await worker.run() +``` + +```typescript +// worker.ts +import { Worker } from '@temporalio/worker' +import * as activities from './activities' + +async function run() { + await activities.prompts.getAll({ label: 'production' }) + + const worker = await Worker.create({ + taskQueue: 'support', + workflowsPath: require.resolve('./workflows'), + activities, + }) + await worker.run() +} +``` + + + +Inside the activity, call `get()` with a `fallback`. When the cache entry has expired and the network is down, the SDK serves the cached value. With no cached value, it serves the fallback. Concurrent activities that ask for the same prompt share one request. + +## Record which version each activity used + +An activity can run more than once. Temporal retries it on failure, and a retry can fetch a newer version if a label moved in between. The activity's return value is the only record that survives in workflow history, so return the prompt name and version from the activity and stamp them on the generation event. Pass `with_metadata=True` in Python to get them. + + + +```python +# activities.py +@activity.defn +async def draft_reply(ticket_text: str) -> dict: + # The Python client is synchronous. Run it in a thread so it does not block + # the other activities on this worker. + result = await asyncio.to_thread( + prompts.get, + 'support-system-prompt', + with_metadata=True, + label='production', + fallback='You are a helpful support agent.', + ) + info = activity.info() + + response = await openai.chat.completions.create( + model='gpt-5', + messages=[ + {'role': 'system', 'content': result.prompt}, + {'role': 'user', 'content': ticket_text}, + ], + posthog_properties={ + '$ai_prompt_name': result.name, + '$ai_prompt_version': result.version, + 'temporal_workflow_id': info.workflow_id, + 'temporal_attempt': info.attempt, + }, + ) + + return { + 'reply': response.choices[0].message.content, + 'prompt_name': result.name, + 'prompt_version': result.version, + 'prompt_source': result.source, + } +``` + +```typescript +// activities.ts +interface DraftReply { + reply: string | null + promptName?: string + promptVersion?: number + promptSource: string +} + +export async function draftReply(ticketText: string): Promise { + const result = await prompts.get('support-system-prompt', { + label: 'production', + fallback: 'You are a helpful support agent.', + }) + const info = Context.current().info + + const response = await openai.chat.completions.create({ + model: 'gpt-5', + messages: [ + { role: 'system', content: result.prompt }, + { role: 'user', content: ticketText }, + ], + posthogProperties: { + $ai_prompt_name: result.name, + $ai_prompt_version: result.version, + temporal_workflow_id: info.workflowExecution.workflowId, + temporal_attempt: info.attempt, + }, + }) + + return { + reply: response.choices[0].message.content, + promptName: result.name, + promptVersion: result.version, + promptSource: result.source, + } +} +``` + + + +`result.source` tells you where the prompt came from: `api`, `cache`, `stale_cache`, or `code_fallback`. On a fallback, `name` and `version` are empty. Decide in the activity whether that is acceptable or whether to fail the activity and let Temporal retry it. + +When every activity in one workflow run must use the same version, read the version from the first activity's result in the workflow and pass it to the later activities, which fetch with `version=` instead of a label. + +## Set timeouts above the fetch timeout + +Each SDK fetch gives up after 10 seconds. Give the activity a `start_to_close_timeout` (Python) or `startToCloseTimeout` (TypeScript) longer than that, so a slow prompt fetch surfaces as a fallback instead of an activity timeout. + +## Shut down the client with the worker + +Generation events are sent in the background. Call `posthog.shutdown()` when the worker stops, so events queued by the last activities are sent before the process exits. + + + +```python +try: + await worker.run() +finally: + posthog.shutdown() +``` + +```typescript +try { + await worker.run() +} finally { + await posthog.shutdown() +} +``` + + diff --git a/src/navs/index.js b/src/navs/index.js index 2ae6ea5ad612..a876a0c6056b 100644 --- a/src/navs/index.js +++ b/src/navs/index.js @@ -7538,6 +7538,10 @@ export const docsMenu = { name: 'A/B testing prompts', url: '/docs/prompt-management/prompt-experiments', }, + { + name: 'Temporal workflows', + url: '/docs/prompt-management/temporal', + }, ], }, { From 4df1bee0257b9e36dc410d1a02a84ecc79f24cdf Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Wed, 14 Oct 2026 10:23:48 +0200 Subject: [PATCH 2/3] docs(prompt-management): export the worker clients and survive a failed cache warm-up --- contents/docs/prompt-management/temporal.mdx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/contents/docs/prompt-management/temporal.mdx b/contents/docs/prompt-management/temporal.mdx index a81e49b258ca..538610a5fd9d 100644 --- a/contents/docs/prompt-management/temporal.mdx +++ b/contents/docs/prompt-management/temporal.mdx @@ -49,11 +49,11 @@ import { PostHog } from 'posthog-node' import { Prompts } from '@posthog/ai' import { OpenAI } from '@posthog/ai/openai' -const posthog = new PostHog('', { +export const posthog = new PostHog('', { host: 'https://us.posthog.com', personalApiKey: '', }) -const prompts = new Prompts({ posthog }) +export const prompts = new Prompts({ posthog }) const openai = new OpenAI({ apiKey: '', posthog }) ``` @@ -63,16 +63,22 @@ const openai = new OpenAI({ apiKey: '', posthog }) The SDK fetches a prompt the first time an activity asks for it, and again when its cache entry expires. Load every prompt in one request before the worker starts polling, so the first activities do not each wait on the network. Fetch at a label, such as `production`, so a label move reaches the worker on the next cache cycle without a deploy. +`get_all()` and `getAll()` raise when the request fails. Log that and start the worker anyway. The activities then fetch on first use and fall back to their `fallback` text if the API is still down. + ```python # worker.py +import logging from temporalio.client import Client from temporalio.worker import Worker from activities import draft_reply, prompts async def main(): - prompts.get_all(label='production') + try: + prompts.get_all(label='production') + except Exception: + logging.exception('Prompt cache warm-up failed, activities will fetch on first use') client = await Client.connect('localhost:7233') worker = Worker(client, task_queue='support', workflows=[...], activities=[draft_reply]) @@ -85,7 +91,11 @@ import { Worker } from '@temporalio/worker' import * as activities from './activities' async function run() { - await activities.prompts.getAll({ label: 'production' }) + try { + await activities.prompts.getAll({ label: 'production' }) + } catch (error) { + console.error('Prompt cache warm-up failed, activities will fetch on first use', error) + } const worker = await Worker.create({ taskQueue: 'support', From 5f59972535ae683ccd5b651ebb4573982c62163b Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Wed, 14 Oct 2026 10:36:45 +0200 Subject: [PATCH 3/3] docs(prompt-management): shut down the shared client and state the full Python fetch budget --- contents/docs/prompt-management/temporal.mdx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/contents/docs/prompt-management/temporal.mdx b/contents/docs/prompt-management/temporal.mdx index 538610a5fd9d..5b78effbf455 100644 --- a/contents/docs/prompt-management/temporal.mdx +++ b/contents/docs/prompt-management/temporal.mdx @@ -200,26 +200,32 @@ When every activity in one workflow run must use the same version, read the vers ## Set timeouts above the fetch timeout -Each SDK fetch gives up after 10 seconds. Give the activity a `start_to_close_timeout` (Python) or `startToCloseTimeout` (TypeScript) longer than that, so a slow prompt fetch surfaces as a fallback instead of an activity timeout. +A prompt fetch can take up to 10 seconds in TypeScript, and up to 30 seconds in Python, where the HTTP transport retries twice with a 10 second limit per attempt. Set the activity's `start_to_close_timeout` (Python) or `startToCloseTimeout` (TypeScript) above that, plus the time the LLM call needs, so a slow fetch ends in a fallback instead of an activity timeout and a retry. ## Shut down the client with the worker -Generation events are sent in the background. Call `posthog.shutdown()` when the worker stops, so events queued by the last activities are sent before the process exits. +Generation events are sent in the background. Call `shutdown()` on the shared PostHog client when the worker stops, so events queued by the last activities are sent before the process exits. ```python -try: - await worker.run() -finally: - posthog.shutdown() +# worker.py +from activities import draft_reply, posthog, prompts + +# ... + + try: + await worker.run() + finally: + posthog.shutdown() ``` ```typescript +// worker.ts try { await worker.run() } finally { - await posthog.shutdown() + await activities.posthog.shutdown() } ```