Skip to main content
Go workflow evaluations are in public preview and can change before reaching general availability.
Requires Go SDK v0.18.0 or later. The Go SDK saves run state in its own format, so start and resume a run with the Go SDK.
Workflow evaluations let you submit tasks or scorers to an asynchronous provider API, such as the OpenAI Batch API, and collect the results later. Use them for evaluations that run through a batch API, which providers often offer at a discount, or that need to continue across process exits. You write functions that submit requests, check whether they’re done, and fetch the results. The SDK calls them, saves progress in a store, and records the outputs and scores in one Braintrust experiment.

How workflow evaluations work

A workflow evaluation has three stages:
  1. Define an evaluation: Build an eval.WorkflowEval with a task, scorers, and a store, and attach your Braintrust client with braintrust.NewWorkflowEval. It works like eval.Eval, except that the task or scorers can send their work to a provider’s asynchronous API.
  2. Start the run: Call Start with your dataset to submit the requests. It returns a run ID right away, without waiting for the provider.
  3. Resume until completion: Pass the run ID to Poll on a schedule, or pass a provider’s completion event to ProcessSubmissionResult from a webhook handler. Each call collects the results that are ready and scores them. The run is done when its status is eval.WorkflowRunCompleted.
The following example walks through each of these stages.

Example: OpenAI Batch API

This example sends questions about capital cities through the OpenAI Batch API and scores each answer against the expected city. Here’s what the program does:
  • Submits the cases to OpenAI together, in one batch.
  • Polls OpenAI until the batch finishes, then collects each answer.
  • Scores each answer with exactMatch, an ordinary scorer, and logs the results to Braintrust.
To get started, create a Go module, add the Braintrust and OpenAI SDKs, and set your BRAINTRUST_API_KEY and OPENAI_API_KEY environment variables:
Then, save the evaluation and its callbacks in evaluation.go. They live in their own file so the Redis and webhook programs later on this page can reuse them:
evaluation.go
Next, save the program that runs the evaluation in main.go:
main.go
Run it with go run evaluation.go main.go. OpenAI batches can take minutes or hours, so the program can run for a while. After each Poll, it prints the run’s status and how many batches are still pending. When it finishes, the Braintrust experiment contains two rows, each with an exact_match score. The rest of this section walks through the example’s three stages.

Define an evaluation

First, newEvaluation in evaluation.go defines the evaluation. In outline, the definition looks like this:
The type arguments are the input type and the output type. The expected output has the same type as the output. The following sections describe each field. For every field, see braintrust.NewWorkflowEval.
ProjectName sets the Braintrust project, "capital-cities-eval" in the example. Braintrust creates the project if it doesn’t exist. Without ProjectName, the evaluation uses the project configured on the client. Each run logs to a new experiment named after Name, unless you set Experiment in eval.WorkflowStartOpts.braintrust.NewWorkflowEval attaches your Braintrust client, so runs create an experiment and log results. Every process that starts or resumes a run must use a definition attached this way. To run without Braintrust, set NoSendLogs in eval.WorkflowStartOpts. The run state is still saved to the store.Name also identifies the run’s records in the store, so keep it, the scorer names, and their order unchanged while runs are active. A process that loads a run with a different definition returns an error.
The dataset goes to Start in eval.WorkflowStartOpts, not in the definition. It accepts the same eval.Dataset values as eval.Eval, such as one built with eval.NewDataset. The SDK saves every case to the store when the run starts, so later calls don’t need the dataset.Each case’s ID becomes its case ID in the experiment rows. When a case has no ID, the SDK generates one when the run starts. Cases can also set Metadata, Tags, and TrialCount. Inputs, expected values, and metadata must be JSON-serializable, so the store can save them.
The Task field accepts an ordinary task function built with eval.T, an eval.WorkflowBatchTask, or an eval.WorkflowTask. Use an eval.WorkflowBatchTask when the provider accepts many requests at once and returns their results later, as batch APIs do. The example’s task has these fields:
  • Batching: MaxSize sets the most items in one batch. Start submits every case and trial right away, in batches of up to MaxSize items.
  • Submit: Sends one batch to the provider. Receives a slice of eval.WorkflowBatchItem values, one for each case and trial, each with the case’s fields in Item and a CustomID that identifies the item. Use the CustomID as the provider’s request ID, such as OpenAI’s custom_id. Returns a json.RawMessage handle, such as the provider’s batch ID. The SDK saves it and passes it to the Completion and Collect callbacks.
  • Completion: How the SDK learns the batch is done: by polling with a function such as the example’s pollBatch, or from a webhook. Set exactly one of its Poll and GetExternalID fields. See eval.WorkflowCompletion.
  • Collect: Fetches the finished batch’s results and calls yield once for each item with an eval.WorkflowBatchResult, which has the CustomID and either a Result with the task’s Output or an Error. Return right away when yield returns an error. The SDK records an item that has an error or no result as failed: its row shows the error, and its scorers don’t run.
The handle must be valid JSON and must not contain clients or credentials, because the store saves it. In the example, it’s {"batch_id": "..."}.Every callback also receives an eval.WorkflowSubmissionContext with the run ID and a SubmissionID. The SubmissionID stays the same if the SDK calls Submit again for the same work, so use it as the provider’s idempotency key if the provider supports one. The example stores both IDs in the batch’s metadata.To send each case as its own request instead, use an eval.WorkflowTask. See Submit one request per case.
Ordinary scorers, like the example’s exactMatch, go in Scorers, and classifiers go in Classifiers. They run in your process as soon as a case’s output is collected. Asynchronous scorers go in WorkflowScorers, which accepts eval.WorkflowBatchScorer and eval.WorkflowScorer values. Use an eval.WorkflowBatchScorer when scoring needs requests that finish later, such as LLM-as-a-judge calls through a batch API.An eval.WorkflowBatchScorer takes the same fields as an eval.WorkflowBatchTask, plus a Name. Its items are eval.WorkflowScorerItem values, which include the task’s Output along with the case’s fields, and each result’s Result is an eval.Scores. A failed item omits that score and records the error in the row’s scorer_errors metadata. See eval.WorkflowBatchScorer.An eval.WorkflowBatchScorer submits a batch once MaxSize items have task outputs, or once no more items can get one. To submit partial batches sooner, set Batching.MaxWait. The SDK submits a partial batch that has waited that long on the next Start, Poll, or ProcessSubmissionResult call.Example: Score with a batch requestThe following newLLMJudge scorer asks a model to grade each answer through the OpenAI Batch API. It reuses the example’s submitBatch, pollBatch, and collectText. To try it, add it to the end of evaluation.go:
Then add WorkflowScorers: []eval.WorkflowScorerDefinition[string, string]{newLLMJudge(client)} to the definition in newEvaluation and run the program again. Your exactMatch scorer runs as soon as the answers arrive. The same Poll submits one grading batch for both answers, and a later Poll collects the grades.
The Store field saves the run’s progress between calls:
  • eval.NewWorkflowEvalMemoryStore(): Keeps progress in memory. Use it when the whole run happens in one process, as in the example.
  • eval.NewWorkflowEvalRedisStore(): Keeps progress in Redis, through a function that runs Redis commands with your Redis client. Use it when the run must survive process exits or be resumed by another process. Records expire after seven days by default.
For Redis options and custom stores, see Workflow stores. Every process that works on a run must use the same store, key prefix, and evaluation definition.

Start the run

Next, main in main.go starts a run by calling Start, which creates the experiment, submits the first batches, and returns an *eval.WorkflowResult with the RunID, without waiting for the provider. With an ordinary task function, Start runs the task and the ordinary scorers for every case before it returns. Save the RunID. You pass it to Poll and Status. If a callback fails after the run is saved, Start returns the run ID along with the error, so you can still resume the run.
Calling Start again creates a new run and submits every request to the provider again. To continue an existing run, pass its run ID to Poll, or pass a completion event to ProcessSubmissionResult.

Resume until completion

Finally, the example calls Poll in a loop until every case is scored. A waiting run can resume in either of two ways, or both:
  • Polling: Call evaluation.Poll(ctx, runID) on a schedule.
  • Webhooks: Call evaluation.ProcessSubmissionResult() when the provider sends a completion event.
Each task and scorer chooses its own method. If the task uses webhooks and a scorer uses polling, you still need to schedule Poll to collect the scorer results. Serialize the calls that advance a run, including across processes. The store’s operations are atomic, but the SDK doesn’t lock a run while it advances it. Different runs can advance at the same time. Check progress. Each call returns a result whose Status stays eval.WorkflowRunWaiting until the run completes, and whose PendingPoll and PendingWebhook fields count the batches not yet collected. To check without advancing the run, call evaluation.Status(ctx, runID). Get the results. When the status is eval.WorkflowRunCompleted, the result’s Scores has the average of each score, Failed counts the cases with a failed request or scorer, and Permalink links to the experiment.
The main example runs in one process that waits until the run finishes, and it keeps progress in memory.For runs that take hours, an alternative is a separate program, such as the following resume.go, that runs in place of main.go. Each time it runs, it does one step and exits, and it saves progress in Redis so the next run continues where the last one stopped. It reuses newEvaluation from evaluation.go. It requires the go-redis client (go get github.com/redis/go-redis/v9) and a REDIS_URL environment variable, such as redis://localhost:6379:
resume.go
To use it:
  • Running go run evaluation.go resume.go with no arguments submits the requests and prints the run ID.
  • Running go run evaluation.go resume.go <run-id> advances the run once. A scheduled job can run it periodically until it prints completed.
Use webhook completion when your provider sends an event when a request finishes. For OpenAI, configure a webhook endpoint for batch.completed, batch.failed, batch.expired, and batch.cancelled events, and use a shared Redis store. The following webhook.go reuses newEvaluation from evaluation.go, with webhook completion:
webhook.go
Set OPENAI_WEBHOOK_SECRET to the endpoint’s signing secret from OpenAI. Start a run with go run evaluation.go webhook.go start, then serve the endpoint with go run evaluation.go webhook.go. The handler returns an error status when ProcessSubmissionResult fails, so OpenAI redelivers the event.Keep in mind:
  • Failed batches: The example passes batch.failed, batch.expired, and batch.cancelled events to ProcessSubmissionResult along with batch.completed. The SDK records each request without a result as failed, so the run still completes.
  • Run ID: The example passes only the batch ID as ExternalID. That works when each batch ID belongs to one run of the evaluation, as OpenAI batch IDs do. If several runs can share an external ID, also set RunID.
  • Security: ProcessSubmissionResult doesn’t check that the event came from the provider. In the example, Unwrap does.
  • Repeated events: The same event can arrive more than once. The SDK ignores a repeated event for a submission it already collected, but your Collect callbacks can run more than once, so keep them free of side effects that can’t repeat.
For how the SDK matches an event to a submission, see eval.WorkflowSubmissionResult.
  • Concurrency: MaxConcurrency limits how many of your callbacks the SDK runs at once during each Start, Poll, or ProcessSubmissionResult call. It defaults to 10. For a batch task or scorer, each Submit call sends one batch.
  • Provider failures: When your polling callback returns eval.WorkflowFailed, Poll returns an error. The SDK checks the batch again on the next Poll, so a permanent failure makes every later Poll return an error, and the run never completes. Start a new run to recover. The example’s pollBatch avoids this by reporting eval.WorkflowComplete for failed, expired, and cancelled batches, so Collect records the requests without results as failed. The SDK doesn’t resubmit or cancel requests that the provider accepted, so retries and cancellation are up to your integration.
  • Errors in your callbacks: For polling, the SDK calls your polling callback and your Collect callback again on the next Poll. For webhooks, Collect runs again only when ProcessSubmissionResult is called again, such as when the provider redelivers the event. If your Submit callback returns an error, the next Poll calls it again with the same items and SubmissionID, for both polling and webhook completion. A crash after the provider accepts a request but before the SDK saves the handle can also call Submit again, so use the SubmissionID as an idempotency key if your provider supports one.
  • Task output: Task outputs must be JSON-serializable. An ordinary task function that returns eval.TaskOutput with UserData fails, because the store can’t save it. Use metadata instead.

Submit one request per case

An eval.WorkflowTask or eval.WorkflowScorer submits one request for each case and trial instead of batching them. Use one when your provider’s asynchronous API takes one request per job, or when each case’s scorers should start as soon as that case’s output is ready. The following task sends each case to OpenAI as its own single-request batch. To try it, replace the eval.WorkflowBatchTask in newEvaluation with this task, and add "errors" to the imports in evaluation.go:
It differs from an eval.WorkflowBatchTask in these ways:
  • Items: Submit receives one eval.WorkflowTaskItem, without a CustomID. The SubmissionID is unique to each case and trial, so the example uses it as OpenAI’s custom_id.
  • Results: Collect returns one result, an eval.WorkflowTaskResult for an eval.WorkflowTask or an eval.Scores for an eval.WorkflowScorer, instead of calling yield.
  • Failures: If Collect returns an error, the SDK calls it again on the next Poll, so a request that can’t succeed, such as one in an expired batch, keeps the run waiting. Start a new run to recover.
  • Scorers: An eval.WorkflowScorer submits each case’s request as soon as that case’s task output is ready.
See eval.WorkflowTask and eval.WorkflowScorer.

Next steps