Make your AI agent reliable with 12-Factor Agents
Make an AI agent you built more reliable with 12-Factor Agents: own its prompt, context and loop, catch errors and ask a person in 60 minutes.

What you need
Step 1: Map your tools as intents
10 minFactor 4 says a tool call is just JSON your code reads, so first list every action your agent can pick.
- Open the factor list and read Factors 1, 4 and 10. Each one is a short page.
- List every tool your agent can call. Give each one an
intentname and the fields it needs. - Add 2 intents that aren't tools:
request_human_inputanddone_for_now. - Count the steps a normal run takes. If it's more than 10 to 20, plan to split the agent.
Your codefrom typing import Literal, Union from pydantic import BaseModel class SearchIssues(BaseModel): intent: Literal["search_issues"] query: str class CreateIssue(BaseModel): intent: Literal["create_issue"] title: str description: str class RequestHumanInput(BaseModel): intent: Literal["request_human_input"] question: str class DoneForNow(BaseModel): intent: Literal["done_for_now"] message: str # The model must answer with exactly one of these NextStep = Union[SearchIssues, CreateIssue, RequestHumanInput, DoneForNow]You should see one class per intent, and a step count for a normal run.
A normal run takes 30 steps or more?
Split the agent into smaller ones with one job each. Factor 10 suggests 3 to 10 steps, 20 at most, with plain code joining them.
The examples use the free Pydantic library to check the model's JSON. Install it with
pip install pydantic.Step 2: Move the prompt into your own code
10 minWhen the prompt lives in your repo, you can read every word, test it and change it like any other code.
- Find where your framework builds the prompt from fields such as role and goal.
- Write the whole prompt in a file of its own, as below, and keep it in git.
- Add a test: feed in a saved thread and check that the model picks the intent you expect.
Your code# prompts/next_step.py: keep it in git, change it by pull request SYSTEM_PROMPT = """You are an agent that [YOUR AGENT'S ONE JOB]. Before any change that can't be undone, ask a person with request_human_input. Answer with one JSON object. Its intent must be one of: search_issues, create_issue, request_human_input, done_for_now.""" def build_prompt(thread_text: str) -> list[dict]: return [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": "Here's everything that happened so far:\n\n" + thread_text + "\n\nWhat's the next step?"}, ]You should see the prompt in its own file and a test that passes.
You can't see the prompt your framework sends?
Log the full request your code sends to the model API for one run. Copy that text into your file as a start.
Step 3: Build the context from one event list
15 minWhen everything the agent knows sits in one list, you control what the model sees, and saving it is easy.
- Store each user message, tool call, result and error as an event with a
typeanddata. - Turn the list into the prompt with one function, like
thread_to_promptbelow. - Keep the current step and retry counts in the same list, not in a separate store.
Your codeimport json def event_to_prompt(event: dict) -> str: data = event["data"] if not isinstance(data, str): data = json.dumps(data, indent=2) return f"<{event['type']}>\n{data}\n</{event['type']}>" def thread_to_prompt(thread: dict) -> str: return "\n\n".join(event_to_prompt(e) for e in thread["events"]) thread = {"id": "[THREAD ID]", "events": [ {"type": "user_message", "data": "[THE USER'S REQUEST]"}, ]} print(thread_to_prompt(thread))You should see a printed prompt where each event is a short tagged block.
The context grows too long?
Drop errors once they're fixed, and cut long tool results down to the fields the model needs. The format is yours to change.
Filter secrets out of events. API keys, passwords and session IDs never belong in a thread.
- Store each user message, tool call, result and error as an event with a
Step 4: Own the loop with a switch on intent
10 minYour code, not the model, decides what happens after each pick, including when to stop and wait.
- Replace the framework's run call with a plain
whileloop, as below.determine_next_stepis your model call. - For a safe tool, run it, add the result to the thread and loop.
- For a high-stakes tool, save the thread, ask for approval and stop. Your
approvedcheck reads the person's reply in the thread. - Add a last branch for any intent your code doesn't know.
Your codedef run(thread: dict): while True: step = determine_next_step(build_prompt(thread_to_prompt(thread))) thread["events"].append({"type": step.intent, "data": step.model_dump()}) if step.intent == "search_issues": # safe: run it and loop result = search_issues(step.query) thread["events"].append({"type": "search_issues_result", "data": result}) continue if step.intent == "create_issue": # high stakes if not approved(thread, step): # no "yes" from a person yet save_thread(thread) request_approval(thread["id"], step) return # stop and wait result = create_issue(step) thread["events"].append({"type": "create_issue_result", "data": result}) continue if step.intent == "done_for_now": save_thread(thread) return step.message # an intent your code doesn't know: say so and loop thread["events"].append({"type": "error", "data": f"Unknown intent: {step.intent}"})You should see the agent looping through safe tools and stopping before a high-stakes one.
The model asks for a tool your code doesn't have?
The last branch adds an error event, so the model can choose again. If it keeps asking, check the intent list in your prompt.
- Replace the framework's run call with a plain
Step 5: Feed errors back in short form
5 minA short error in the context helps the model fix its next call, and a limit stops it repeating a mistake.
- Wrap each tool call in
tryandexcept. - On an error, add a short error event: the error type and a trimmed message.
- Count errors in a row. After 3, stop and hand the thread to a person.
Your codedef format_error(e: Exception) -> str: return f"{type(e).__name__}: {str(e)[:300]}" errors_in_a_row = 0 # set once, before the while loop # inside the loop, around each tool call try: result = run_tool(step) thread["events"].append({"type": step.intent + "_result", "data": result}) errors_in_a_row = 0 except Exception as e: errors_in_a_row += 1 if errors_in_a_row >= 3: save_thread(thread) notify_human(thread["id"], format_error(e)) # hand it to a person return thread["events"].append({"type": "error", "data": format_error(e)})You should see a failed call in the thread, then a corrected call right after it.
The agent repeats the same error?
Change how the error reads, or remove the earlier failed events from the context. Smaller agents also get stuck less often.
- Wrap each tool call in
Step 6: Ask a person with a tool call, then resume
10 minAn agent that can pause and pick up where it left off can be trusted with bigger tasks.
- Handle
request_human_inputin your loop: save the thread, send the question and stop. - Send the question where your team already works, such as Slack or email.
- When the reply comes in, load the thread by its ID, add the reply as an event and run the loop again.
Your code# in the loop: pause and ask a person if step.intent == "request_human_input": save_thread(thread) notify_human(thread["id"], step.question) # Slack, email or a webhook return # your webhook: runs when the person replies def on_reply(thread_id: str, reply: str): thread = load_thread(thread_id) thread["events"].append({"type": "human_response", "data": reply}) run(thread) # carries on from the same stepYou should see the agent waiting on a question, then carrying on from the same step after the reply.
The agent starts over after a person replies?
Load the saved thread by its ID before you run the loop. A new thread has no record of the earlier steps.
- Handle
You get
An agent with its own prompt, context and loop that recovers from errors and asks a person before risky actions
Try next
Did this guide work for you?
Thanks. It helps us fix the steps.


