I have been reading about "AI agents" and "workflows", and about SDKs like flueframework, LangGraph, the Vercel AI SDK, and Ruby options like RubyLLM. I want to write down what these things actually are, in plain terms, so we can study it together and decide where it fits in Jod.
This post uses our own gig domain — Gig::Shift, Gig::PayRate, suspensions, auto-selection — as the examples. If you understand how we post a shift and pay a worker, you already understand most of what you need.
This is a study note, not a decision. It is the shared vocabulary. When we later say "let us make the appeal review a workflow, not an agent", this post explains what that sentence means.
The one idea everything hangs on
Take away the hype. A large language model (LLM) is a function that takes text and returns text.
output_text = model(input_text)
That is all it is. Three properties matter:
- It is stateless. It remembers nothing between two calls. Nothing.
- It has no side effects. It cannot read our database. It cannot send an email. It cannot charge a card. It only produces text.
- It is not deterministic. The same input can give a slightly different output.
Hold this picture. Almost everything people call "memory", "tools", "agents", or "RAG" is scaffolding we build around this one stateless text function to make it useful in a real business.
Two things follow directly:
- Because the model has no memory, any idea of "the conversation" or "the agent knows X" really means we fed that text back into the next call. Memory is something we manage in our code, not something the model has.
- Because the model cannot touch the world, it can only ever propose. Something in our code always decides and acts. That gap is where all our safety lives.
From a text function to an action: tools
A text function cannot post a Gig::Shift. So we play a structured game with it. We give the model a menu of functions it is allowed to ask for. We describe each one as a schema (the function name plus its typed inputs):
You may ask to call any of these:
- resolve_pay_rate(outlet_id, role_id, starts_at)
- create_draft_shift(job_id, starts_at, ends_at, headcount)
- search_outlets(query)
The model cannot run these. It can only emit a message that says "I want to call resolve_pay_rate with outlet_id: 42, role_id: 7, starts_at: 2026-07-05T09:00+08:00".
Our Rails code then runs the real method. We take the result and feed it back into the next model call as more text.
The model never touches Gig::PayRate. It never walks our fallback chain. It only asks. Our own tested method does the real work and returns a fact.
A "tool" is not a new capability. A tool is a service object we already have, wrapped with a schema that describes its inputs. resolve_pay_rate is our existing Gig::PayRate.resolve!. create_draft_shift is our existing shift manager. We are not giving the model new powers. We are giving a text-driven caller a way to ask for powers we already built.
The authority boundary — the most important idea for us
Read this section twice. The line between "the model decides" and "our code acts" is called the authority boundary (the point where a proposal from the model becomes a real action in our system).
Our invariants live on our side of that line. The invariant is the business rule that must always hold — for example:
- publishing a shift reserves gig credits so an employer cannot overspend (
headcount × duration × rate),
- the suspension tiers escalate
Warning → 7d → 30d → 90d,
- billable time locks at
9:00am the next day,
- a
Gig::Job moves :active → :archived, never a hard delete.
These rules live in Rails, and the tool itself enforces them. The model proposes; our domain decides.
This is why a model that is confident but wrong is still safe. Say the model asks to create a shift with a rate it made up. Gig::PayRate.resolve! ignores the made-up number and resolves the correct rate from the fallback chain. The tool validates. The model cannot break an invariant by sounding sure.
Never let the model be the source of truth for a rule that protects money, time, or a worker's record.
The model can draft, classify, extract, and summarise. It must not be the thing that decides whether the credit reservation passes, whether a suspension escalates, or whether a payment is created. Encode the rule in Rails. Let the model work inside the guardrail.
What an "agent" really is: a loop
Once the model can ask for tools and read results, we can put it in a loop. This loop, about twelve lines, is the whole thing. There is no magic.
messages = [system_prompt, user_goal]
loop do
reply = model.call(messages, tools: TOOL_SCHEMAS)
break if reply.done? # the model says the task is complete
result = execute_tool(reply.tool_call) # OUR code runs the real method
messages << reply # remember what the model asked
messages << result # remember what actually happened
end
The loop is: the model picks an action, our code runs it, the result goes back, repeat until the model says it is done. That loop, plus a set of tools, plus a goal, is an agent.
Here is a real shape for Jod. Imagine an "employer assistant" where a FairPrice manager types:
"I need 3 servers at the Orchard outlet this Saturday, 6pm to midnight."
The agent loops:
- asks to call
search_outlets("Orchard") and gets the outlet_id
- asks to call
resolve_pay_rate(outlet, "server", saturday_6pm) and gets the weekend night-band rate
- asks to call
create_draft_shift(...) and gets a draft back
- replies: "Draft created, 3 servers, Saturday 18:00 to 00:00, rate
$X/hr. Please review and publish."
The model chose that order. Nobody hard-coded "first search, then resolve the rate, then draft". That runtime choice of steps is the one thing that makes it an agent.
Workflow vs agent — the decision we will keep making
This is the fork we will stand at again and again. The cleanest way to see it:
| Workflow | Agent |
|---|
| Who decides the steps? | We do, in code | The model does, at runtime |
| Control flow | fixed, written by us | discovered as it runs |
| The model's job | fill in a few fuzzy steps | decide what to do next |
| Predictable and testable? | yes | less so |
| Cost and latency | lower, bounded | higher, open-ended |
| Use when | the shape of the work is known | the work is genuinely open-ended |
A workflow is normal Rails code where a few steps happen to call an LLM. We write the order. The model only handles the parts that need to read or write natural language.
An agent lets the model decide the order.
A simple test: can you draw the flowchart before it runs?
- If yes, it is a workflow. Encode the flowchart in Rails, and drop the model into the fuzzy boxes.
- If the flowchart depends on what the model finds as it goes, it is an agent.
Our default should be workflow-first. Most of the value we want — triage, extraction, classification, drafting — is workflows. Agents are a smaller and more expensive special case. A workflow fails in bounded ways we can test. An agent fails in open-ended ways: wrong tool, a loop that does not stop, drift away from the goal. For a domain that moves money and touches a worker's suspension record, bounded failure is worth a lot.
What the SDKs actually give us
We could write that twelve-line loop ourselves. For a first demo we should. The SDKs (flueframework, LangGraph, RubyLLM, Raix, the Vercel AI SDK) earn their place on the boring, hard parts. They do for agents what Rails does for HTTP. We could write raw Rack, but the framework gives us conventions for:
- The tool-calling loop — turning our Ruby methods into schemas and back, parsing the model's requests, running the loop.
- Structured output with validation — "give me back
{role, outlet, headcount, starts_at}" and guarantee it matches a schema, retrying the model if it does not. This turns unreliable text into typed data.
- State and memory — what text gets carried into the next call, and how to shorten it when it grows too long.
- Orchestration parts — chains, routers, running steps in parallel, and human-in-the-loop pauses (stop, wait for a human to approve, then continue).
- Retries and fallbacks — model A fails, try model B; a tool raises, feed the error back so the model can recover.
- Tracing — a full record of every prompt, tool call, and result for each run. In production this is the part we will care about most, because it answers "why did it do that?".
- Streaming — sending output to our React screen token by token.
For us specifically: we can keep the agent layer in Ruby (RubyLLM, Raix, langchainrb) so the tools are literally our service objects, running in the same process with no network hop. Or we run a separate TypeScript or Python service that calls Rails over HTTP.
In-Ruby means the tools touch our domain directly, and there is no second copy of the schema to keep in sync. A separate service gives us the most mature tooling. Most teams start in the same language and split out only if they have to.
The framework choice is not the important one. The frameworks are thin and swappable. Our durable assets are our tool definitions and our tests, not the orchestration library.
Four Jod examples, from least to most autonomy
This ladder is the practical heart of the post. Same domain, four levels of autonomy. Notice the value is highest, and the risk lowest, at the bottom.
Level 0 — no LLM at all
Pay-rate resolution. Walking the fallback chain is fixed business logic:
- outlet-specific time band →
- company-wide time band →
- outlet-specific day_type →
- company-wide day_type →
- default rate.
The day_type comes from Geo::PublicHoliday, weekend, or weekday. The input is structured. The rule is known. This is a plain Gig::PayRate.resolve! method.
An LLM here would be slower, cost money, and be wrong sometimes, for zero benefit. The first skill is knowing when not to reach for a model at all. If the input is structured and the rule is known, it is code.
Level 1 — workflow: a chain of fixed steps
Suspension appeal review. A talent cancels a McDonald's shift 2 hours before it starts, gets a pending suspension, and uploads a medical certificate (MC) inside the 24-hour appeal window. The steps are fixed Rails code:
1. (Rails) guard: is this still inside the 24-hour appeal window? <- invariant, never the model
2. (LLM) read the uploaded document. is it a medical certificate?
extract { issue_date, covered_dates, clinic }
3. (Rails) do covered_dates include the shift date? <- fixed check
4. (LLM) draft a short, plain summary for the Ops reviewer
5. (Rails) put it in the queue for a human (Kimberly) to approve or reject
The model does only what it is good at: turning a photo or PDF into structured fields and a readable summary. Every rule that matters — the 24-hour window, the tier escalation, the final approve or reject — stays in Rails or with a human. This is a workflow, and this is where we should start.
Level 2 — workflow: routing
Inbound message triage. Employers and talent send free-text messages. One model call reads the message and returns a label. A Rails case statement routes it:
- payment dispute → Finance
- cancellation emergency → Ops
- "how do I post a shift" → self-serve help
The skeleton is fixed. Only the classification step is fuzzy. Still a workflow.
Level 3 — agent: the model drives
The employer assistant from earlier — post a shift by chatting. Here we do want the model to choose the order of tools, because a real conversation is open-ended ("actually make it 4 people, and what would Sunday cost?"). Tools: search_outlets, resolve_pay_rate, check_credit_balance, create_draft_shift.
One rule holds it together: create_draft_shift creates a draft, not a published shift. Publishing reserves credits, so a human clicks "publish". The agent gets freedom over the conversation. It never gets freedom over spending money.
Level 4 — high autonomy, where I would push back
A tempting target: an "auto-staffing agent" that watches under-filled shifts and runs the selection on its own.
But our auto-selection is already a fixed, written rule (AM shift deadline 9:00am the previous day, PM shift deadline 5:00pm the previous day, skip suspended or overlapping talent, pick the top-ranked by our score: show-up 30%, cancellation history 25%, experience 25%, ratings 20%). That is a scheduled job, not an agent.
Handing ranking and selection — which directly creates Gig::Assignment and then Gig::Payment — to a model that is not deterministic trades a rule we can test for one we cannot. When the flowchart already exists, an agent is a step backward.
Look at the pattern down the ladder. The model's real job is to turn free text into structured data at the edge of the system, while our fixed core — rates, windows, tiers, credit reservation — stays in Rails. Push the fuzzy work to the edge. Keep money and invariants in the middle.
Risk rises as we give away more control, but value does not. Most of our return is at levels 1 and 2.
The checklist for deciding
Six questions to run any proposal through:
- Does this even need a model? Structured input and a known rule → code. Free text or a genuinely fuzzy judgment → a model earns its place. The pay-rate chain is code. "Is this cancellation a real emergency?" is fuzzy.
- Workflow or agent? Can we draw the flowchart first? Then it is a workflow. This is the default.
- Where is the authority boundary? Invariants live in Rails, enforced by the tool. The model proposes; our domain decides. A confident wrong answer must be unable to break a rule, because the tool will not let it.
- How will we test it? We cannot
assert_equal a prompt. The new discipline is an eval set (a labelled dataset of past cases — for example 200 past appeal documents tagged valid-MC / fake / not-relevant — that we score the model against). This is our CI for something that is not deterministic. We must budget time for it.
- What is the cost and latency? Each tool step is one round-trip to the model, run one after another. An 8-step agent is 8 calls in a row: tokens and
p95 latency add up, and the text we send grows every step, because re-sending the history is the model's only memory. Workflows are bounded and often cacheable. Open-ended agents are not.
- Can we see what it did? In production, tracing every prompt, tool, and result is not optional. Without it, "why did it draft the wrong rate?" has no answer.
Where this lives in a Rails and React system
The agent or workflow logic lives in the Rails backend, because that is where the tools live — the tools are our domain. React's job is thin: a chat screen, streaming the reply, and a screen where a human approves.
Do not put business-critical orchestration in the browser. The model's output is untrusted input, the same as any user form. We validate at the boundary and trust nothing that came from the model until a tool has checked it.
"RAG" is worth demystifying while we are here. Retrieval-Augmented Generation is just a tool that fetches relevant text and pastes it into the prompt. "Search the gig FAQ, take the top 3 chunks, put them in the prompt, then answer." It is one more tool, not a separate architecture. Do not let the acronym make it sound bigger than it is.
Why Jod is well placed for this
Here is the part I find most interesting. The hard part of putting a model into a business is not the model or the framework. It is having a clean domain layer, with well-defined operations and clear invariants, to expose as tools. An agent on top of a messy codebase is dangerous, because there is no tool that safely enforces the rules.
Our doc-driven work is already that layer:
- our use cases (
UC-1 publish, UC-3 auto-select, and the rest) are already named operations with preconditions → this is our tool catalog
- our model specs with explicit invariants (double handshake, settlement window, the three definitions of time, credit reservation) → these are our guardrails, the rules the tools must enforce so the model cannot break them
- our state machines → the legal transitions an agent is allowed to ask for
So the move is not "go build an autonomous agent". The move is: keep building the clean, invariant-rich domain layer we are already building. Then model features become a thin, low-risk layer of natural language over a core we can trust. The domain model is our real advantage. The framework on top is swappable plumbing.
Open questions to discuss later
Things I want us to think through together:
- Which level-1 workflow should be our first real one? The suspension appeal review is a strong candidate — it is high-effort for Ops today, and the invariants are all on our side.
- Do we keep the agent layer in Ruby (tools = our service objects, in-process) or run a separate service? What do we lose either way?
- What would our first eval set look like, and who owns it? Without it we cannot safely change a prompt or a model.
- What is the smallest tracing setup that still lets us answer "why did it do that?" in production?
If any part of this is unclear on one read, tell me which paragraph, and I will rewrite it plainer.