Temporal
Wait on condition(), resume with signal hitly.decision.
Temporal pauses with condition() until a Signal (or times out). HITLy does not hold a worker; it signals the workflow.
How it works with HITLy
- An activity POSTs an approval to HITLy
/api/v1/approvals. - The workflow
await condition(() => decision !== undefined, timeout). - Reviewer decides in the HITLy inbox.
- HITLy sends signal
hitly.decisionwith{ decision, args }. - The workflow checks
decision:accept→ proceed with the actionreject→ bail without taking the actionedit→ apply edited arguments, then proceed
See the runnable project in examples/temporal (TypeScript refund workflow with worker and client).
Setup
Create a workspace, API key, and Temporal connection (address, namespace, and optional apiKey for Temporal Cloud).
Set env:
HITLY_API_URL=http://localhost:3001
HITLY_API_KEY=hitly_...
HITLY_PROJECT_ID=prj_...
TEMPORAL_ADDRESS=localhost:7233
TEMPORAL_NAMESPACE=defaultIntegration guide (TypeScript)
1. Install dependencies
npm install @temporalio/activity @temporalio/client @temporalio/worker @temporalio/workflow2. Create HITLy notify activity
import type { NotifyHitlyInput } from './types'
export async function notifyHitlyActivity(input: NotifyHitlyInput): Promise<void> {
const payload = {
plugin: 'temporal',
projectId: process.env.HITLY_PROJECT_ID,
workflowId: input.workflowId,
address: process.env.TEMPORAL_ADDRESS,
namespace: process.env.TEMPORAL_NAMESPACE ?? 'default',
actionName: 'send-refund',
args: { orderId: input.orderId, amount: input.amount },
contextMarkdown: `Refund of $${input.amount} for order ${input.orderId}.`,
// Evidence fields
systemId: 'refund-workflow-prod',
inventoryId: 'ai-inv-refund-workflow-v1',
policyId: 'refund-over-100',
riskTier: input.amount > 1000 ? 'high' : 'medium',
}
const response = await fetch(`${process.env.HITLY_API_URL}/api/v1/approvals`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${process.env.HITLY_API_KEY}`,
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(`HITLy ingest failed: ${await response.text()}`)
}
}3. Workflow with condition() and signal
import { proxyActivities, defineSignal, setHandler, condition } from '@temporalio/workflow'
import type * as activities from './activities'
const { notifyHitlyActivity, issueRefundActivity } = proxyActivities<typeof activities>({
startToCloseTimeout: '60 seconds',
})
// Define the hitly.decision signal
export const hitlyDecisionSignal = defineSignal<[HitlyDecision]>('hitly.decision')
export async function refundWorkflow(input: RefundInput): Promise<string> {
let decision: HitlyDecision | undefined = undefined
// Set up signal handler
setHandler(hitlyDecisionSignal, (payload: HitlyDecision) => {
decision = payload
})
// Notify HITLy
const workflowId = (globalThis as any).workflowInfo?.().workflowId ?? 'unknown'
await notifyHitlyActivity({ workflowId, ...input })
// Wait for signal with timeout (5 minutes)
const signalReceived = await condition(() => decision !== undefined, '5m')
if (!signalReceived || !decision) {
throw new Error('Timeout waiting for HITLy decision')
}
// Check decision
if (decision.decision === 'reject') {
return `Refund rejected. No refund issued for order ${input.orderId}.`
}
// Apply edited args if present
const finalArgs = {
orderId: decision.args?.orderId ?? input.orderId,
amount: decision.args?.amount ?? input.amount,
}
// Issue refund
return await issueRefundActivity(finalArgs)
}Running locally
yarn workspace @hitly/app dev— HITLy on port 3001.temporal server start-dev— Temporal server on port 7233.cd examples/temporal && yarn worker— Worker listening onhitly-refund-queue.cd examples/temporal && yarn start-workflow— Start a refund workflow.- Open the HITLy inbox at http://localhost:3001/inbox to approve or reject.
Production
HITLy must reach address to signal workflows. Do not point a hosted HITLy workspace at localhost. Use Temporal Cloud or a publicly accessible Temporal server.
For Temporal Cloud, set:
TEMPORAL_ADDRESS=your-namespace.tmprl.cloud:7233
TEMPORAL_NAMESPACE=your-namespace.production
TEMPORAL_API_KEY=your-api-keyAnd configure the Temporal connection in HITLy with the address, namespace, and API key.
Configuration options
| Option | Use it to |
|---|---|
address | Temporal frontend address (e.g. localhost:7233) |
namespace | Temporal namespace (e.g. default) |
workflowId | Workflow to signal (stored on OriginRef.runId) |
| Signal name | hitly.decision |
Evidence Fields (Optional)
For audit and compliance, pass evidence context in the notify call:
| Field | Description |
|---|---|
systemId | AI system being governed (e.g. "refund-workflow-prod") |
inventoryId | This AI system's record in your inventory/registry (Layer 1) |
policyId | Policy that triggered approval |
policyRationale | Why approval is required |
riskTier | Risk level (e.g. "high", "medium", "low") |
toolName | Tool or action name |
sensitivity | Sensitivity flags (not raw PII) |
data_categories | Data category flags (GDPR/compliance) |
Evidence events are written to the configured HTTP sink (project settings → Evidence Storage). See Envelope for full details.
Decision mapping
| HITLy | Signal payload |
|---|---|
accept | { decision: 'accept' } |
edit | { decision: 'edit', args: { orderId, amount } } |
reject | { decision: 'reject' } |
Checkpointer and timeout notes
Checkpointer N/A. Temporal workflows are durably persisted by default. condition() timeout is the workflow's problem — HITLy stays pending until decide/expiry/cancel.
Fail-closed note: Sink 5xx / hang >5s → pending, no signal, store-failed UI.
Isolation: A's workflowId is never signaled with B's credentials/namespace. B deciding A is 404.
Related
- Envelope
- API
- Example:
examples/temporal - Temporal Approval Pattern