In the CrewAI Python framework you assemble teams of agents that collaborate via LLMs. It’s a great model when tasks require multiple specialists. But pulling in litellm, langchain, pydantic and friends makes cold starts heavy and deployment a container story.
A few months ago I started crewai-go, an idiomatic Go port with zero external dependencies. The whole core package is pure net/http, encoding/json, log/slog and friends.
This post walks through a minimal port of the canonical CrewAI “research → write” example, side-by-side with the Python version.
Python (CrewAI)
from crewai import Agent, Crew, Process, Task
researcher = Agent(
role="Senior Researcher",
goal="Uncover the best practices in concurrency",
backstory="You are a veteran engineer with deep distributed-systems chops.",
)
writer = Agent(
role="Tech Writer",
goal="Write a concise summary",
backstory="You turn research into tight prose.",
)
research = Task(description="Research Go concurrency best practices",
expected_output="Bullet list of 5 best practices", agent=researcher)
write = Task(description="Write a 1-paragraph summary from the research",
expected_output="A single paragraph", agent=writer)
crew = Crew(agents=[researcher, writer], tasks=[research, write],
process=Process.sequential)
print(crew.kickoff().final)
Go (crewai-go)
package main
import (
"context"
"fmt"
"github.com/rhgs/crewai-go"
"github.com/rhgs/crewai-go/llm/openai"
)
func main() {
llm := openai.New("gpt-4o-mini") // uses OPENAI_API_KEY
researcher := crewai.NewAgent(
"Senior Researcher",
"Uncover the best practices in concurrency",
"You are a veteran engineer with deep distributed-systems chops.",
llm,
)
writer := crewai.NewAgent(
"Tech Writer",
"Write a concise summary",
"You turn research into tight prose.",
llm,
)
research := crewai.NewTask(
"Research Go concurrency best practices",
"Bullet list of 5 best practices",
researcher,
)
write := crewai.NewTask(
"Write a 1-paragraph summary from the research",
"A single paragraph",
writer,
).WithContext(research) // explicit dependency, like CrewAI’s context
crew := crewai.NewCrew([]*crewai.Agent{researcher, writer},
[]*crewai.Task{research, write})
out, _ := crew.Kickoff(context.Background(), nil)
fmt.Println(out.Final)
}
- Same surface, different runtime
- Same concepts: Agent, Task, Crew, Process.
- Sequential, Hierarchical and Staged processes (stages run in sequence; tasks within a stage run concurrently).
- Agentic loop (opt-in Plan–Execute–Evaluate–Refine) with an independent evaluator and bounded refinements.
- Web search, structured output with JSON Schema repair loop, guardrails, facts & provenance — all of it built on stdlib only.
Why port it?
- Single binary (GOOS=linux GOARCH=arm64 go build from your laptop).
- Millisecond cold start, ~10–20 MB memory footprint.
- Thread-safe end-to-end; CI runs go test -race.
- ~94% test coverage, hard 90% gate.
Try it
git clone https://github.com/rhgs/crewai-go
cd crewai-go
export OPENAI_API_KEY=sk-...
go run ./examples/sequential
There’s also examples/agentic_loop which runs fully offline with a mock LLM — no API key needed.
If you’re a Go developer working on LLM orchestration, give it a star, file an issue, or open a PR. The CONTRIBUTING guide is bilingual (English + Brazilian Portuguese) and the PR checklist is short.
Repo: https://github.com/rhgs/crewai-go v0.4.0 release: https://github.com/rhgs/crewai-go/releases/tag/v0.4.0
Happy hacking.
Top comments (6)
Really interesting approach, especially the staged execution model.
One thing I'm curious about:
go test -racegives you confidence against memory-level data races, but how are you handling semantic races when multiple tasks inside a stage run concurrently?For example, if two agents produce facts or context that later get merged into the next stage, can completion order affect the final prompt/state?
Did you make stage outputs immutable and merge them deterministically, or is ordering deliberately part of the orchestration semantics?
Great question — -race is necessary but not sufficient. Merge is part of the orchestration contract, not an accident of scheduling.
Intra-stage tasks are independent. They don't read each other's outputs, facts, or warnings while the stage is running. Same-stage Task.Context isn't supported: a sibling dependency would be a semantic race. The graph is “stage N may depend on stages < N”.
The stage is a barrier, then a deterministic fold. Each task writes into a slot by declaration index. After wg.Wait(), we aggregate in slice order, not completion order — that's what feeds TasksOutput, Facts, Warnings, and Final. TestStagedDeterministicOrder covers the slow-first / fast-second case.
Context into the next stage is explicit and ordered. Later tasks opt in with WithContext(...). contextText() walks that slice in declaration order and reads write-once, mutex-protected outputs. The next stage doesn't start until Wait returns, so completion order can't change the prompt. Facts aren't auto-injected into the next prompt; they ride on CrewOutput. Crew-level merge is dedupFacts by PayloadHash, first occurrence in declaration order wins.
Caveat: Memory. Save is race-detector-safe, but append order follows completion. A later task with no WithContext that falls back to accumulated memory can see completion order. Don't use Memory as the merge channel for staged siblings — use WithContext. That's the semantic-race surface we left explicit rather than pretending -race closed it.
That Memory caveat is the interesting part.
If completion order is intentionally observable through Memory, how do you prevent a future consumer from accidentally turning that into an implicit dependency?
Have you considered buffering memory writes per task and folding them at the stage barrier too, or do you specifically want Memory to preserve execution order as an event log?
I’m wondering where you draw the line between “nondeterminism by design” and “an orchestration invariant the API should make impossible to violate.”
Hi! Since my last post, the application has evolved significantly to address exactly this.
To prevent consumers from creating implicit dependencies, the architecture now specifically buffers memory writes per task and only folds them into the shared context at the stage barrier. This treats data isolation as a strict orchestration invariant-tasks in the same concurrent stage cannot read each other's partial states. Meanwhile, the execution order is still preserved strictly as an event log for observability, entirely separate from the agent's working memory.
You can check out the complete breakdown of the updated concurrency model and how this memory caveat is handled in detail here: github.com/rhgs/crewai-go/blob/mai...
That's a much cleaner boundary — separating the execution log from working memory removes the ambiguity I was worried about.
The per-task buffering introduces another interesting question though: what are the commit semantics when a stage partially fails?
If three concurrent tasks succeed, one errors, and another is cancelled, do the successful tasks' buffered memory writes still get folded at the barrier, or is the stage treated atomically and the whole memory commit discarded?
I'm curious whether you model the barrier more like a deterministic merge point or like a transaction boundary.
It's modeled as a deterministic merge point, not an atomic transaction boundary.
Partial Commits: The 3 successful tasks will have their buffered writes committed to the shared store at the barrier in their original declaration order.
Failed/Cancelled Discard: The per-task buffers for the errored and cancelled tasks are simply discarded—no partial or corrupted state from those tasks enters the memory store.
Non-Atomic Stage: The stage is not rolled back as a whole. Since the successful tasks finished valid work, their findings are kept in memory for subsequent waves to consume.
The barrier's primary job is to enforce isolation during execution (so sibling tasks never read each other's in-flight writes) and to keep memory commits strictly deterministic regardless of goroutine scheduling.