Skip to content

LearnTutorials

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.

By Recaply EditorsAdvanced60 min6 stepsUpdated

A toy train waits on a curved track by a round signal post while a hand presses a desk bell

What you need

  1. Step 1: Map your tools as intents

    10 min

    Factor 4 says a tool call is just JSON your code reads, so first list every action your agent can pick.

    1. Open the factor list and read Factors 1, 4 and 10. Each one is a short page.
    2. List every tool your agent can call. Give each one an intent name and the fields it needs.
    3. Add 2 intents that aren't tools: request_human_input and done_for_now.
    4. Count the steps a normal run takes. If it's more than 10 to 20, plan to split the agent.
    Your code
    from 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.

  2. Step 2: Move the prompt into your own code

    10 min

    When the prompt lives in your repo, you can read every word, test it and change it like any other code.

    1. Find where your framework builds the prompt from fields such as role and goal.
    2. Write the whole prompt in a file of its own, as below, and keep it in git.
    3. 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.

  3. Step 3: Build the context from one event list

    15 min

    When everything the agent knows sits in one list, you control what the model sees, and saving it is easy.

    1. Store each user message, tool call, result and error as an event with a type and data.
    2. Turn the list into the prompt with one function, like thread_to_prompt below.
    3. Keep the current step and retry counts in the same list, not in a separate store.
    Your code
    import 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.

  4. Step 4: Own the loop with a switch on intent

    10 min

    Your code, not the model, decides what happens after each pick, including when to stop and wait.

    1. Replace the framework's run call with a plain while loop, as below. determine_next_step is your model call.
    2. For a safe tool, run it, add the result to the thread and loop.
    3. For a high-stakes tool, save the thread, ask for approval and stop. Your approved check reads the person's reply in the thread.
    4. Add a last branch for any intent your code doesn't know.
    Your code
    def 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.

  5. Step 5: Feed errors back in short form

    5 min

    A short error in the context helps the model fix its next call, and a limit stops it repeating a mistake.

    1. Wrap each tool call in try and except.
    2. On an error, add a short error event: the error type and a trimmed message.
    3. Count errors in a row. After 3, stop and hand the thread to a person.
    Your code
    def 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.

  6. Step 6: Ask a person with a tool call, then resume

    10 min

    An agent that can pause and pick up where it left off can be trusted with bigger tasks.

    1. Handle request_human_input in your loop: save the thread, send the question and stop.
    2. Send the question where your team already works, such as Slack or email.
    3. 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 step

    You 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.

You get

An agent with its own prompt, context and loop that recovers from errors and asks a person before risky actions

Did this guide work for you?

Sources