From Prompt to Agent System With Kimi K3: the 9-step roadmap

🔵 An agent system is different in one specific way: it decides what to do next, does it, checks the result, and stops itself. The model is the same. Everything around it is the difference.
This is the capstone of the series. Every piece we built separately, the loop, the gate, the state, the router, the budget, assembles here into one working system, wired to a single model: Kimi K3.
⚙️ K3 is a good choice for this precisely because it is not a safe one. It is cheap enough to run in a loop, capable enough to use tools well, and it hallucinates more than its predecessor, which means the parts of the system that catch its mistakes are not decoration. They are load-bearing.
This is the 9-step roadmap from one prompt to an agent system: the call, the loop, and the system that wraps them. No framework. The whole thing is about 150 lines on top of the OpenAI SDK.
01. Start with one call to K3
K3 speaks the OpenAI Chat Completions API. If you have called GPT, you have already called K3. The only differences are the base URL and the model name.
from openai import OpenAI
client = OpenAI(api_key=KEY, base_url="https://api.moonshot.ai/v1")
resp = client.chat.completions.create(model="kimi-k3", messages=msgs, tools=tools)That is the entire integration. Everything from here is not about the call. It is about what you build around it, and the article treats the call itself as a solved problem, stubbed so the rest runs without a key.
02. Constrain the output
A raw prompt returns prose. A system needs a shape it can act on. Give K3 a contract: a schema it must fill, so the next step in your code can rely on the structure instead of parsing English.
The contract lives in the system message and, for anything the model should be able to do, in a tool schema. Both are just data you pass on every call.
03. Give K3 tools
A tool is a plain function plus a schema describing it. This is what turns a text generator into something that can act on the world.
def search(q: str) -> list[str]:
return SEARCH_FIXTURES.get(q, [])
TOOL_SCHEMA = [{
"type": "function",
"function": {
"name": "search",
"description": "Search the corpus. Use before answering any factual claim.",
"parameters": {"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"]},
},
}]Note the description. It does not just say what the tool is, it says when to use it: before any factual claim. With a model that hallucinates, the tool description is your first line of defense, because it is where you tell K3 to go check.
04. Close the loop
One call cannot finish a real task, because K3 does not have the answer, it has the ability to ask for tools that lead to it. The loop keeps calling K3, running whatever tool it asks for, feeding the result back, and calling again.
The continue is the loop. K3 asks, the runtime answers, K3 asks again, until it stops asking. In the real run K3 called search twice, on two different queries, before it had enough to answer. Nobody scripted that sequence. It is what the model decided, given the tools.
05. Add a stop gate
A loop with no exit is a bill with no ceiling. The stop condition here is simple: the moment K3 returns an answer instead of a tool call, the loop ends and the answer goes to verification. But there is also a hard cap on steps, because a model that keeps asking for tools forever needs to be stopped by the runtime, not trusted to stop itself.
for n in range(1, max_steps + 1):
...
if turn.tool:
...
continue
# not a tool call -> it's an answer -> leave the loop
...
return state, trace
trace.append(Step(max_steps, "halt", "max steps reached"))Two ways out: the answer, or the cap. Never no way out.
06. Carry state across turns
K3 has no memory between calls. Every turn re-sends the entire conversation, so the state is the message list, and it grows with every tool result.
@dataclass
class State:
messages: list = field(default_factory=list)
corpus: list = field(default_factory=list) # everything tools returned
tokens: int = 0
def add(self, role: str, content: str) -> None:
self.messages.append({"role": role, "content": content})K3's million-token window makes this feel free. It is not. Every turn re-sends the whole state, so a window you can fill is a bill you can run up. The discipline is to carry what matters and prune tool results once they are summarized, or the cost of turn ten is the cost of turns one through nine, again.
07. Route between paths
Not every task deserves the loop. A trivial request should get a direct answer; a factual one should earn the full tool-and-gate treatment. The router is the function that decides, and it is the cleanest line between calling a model and running a system.
def route(task: str) -> str:
if len(task) < 40 and "?" not in task:
return "direct" # trivial, skip the tool loop
return "tool_loop"This one is deliberately crude. The point is not the heuristic, it is that a system makes the routing decision explicitly, in code you can inspect, rather than sending everything down the most expensive path by default.
08. Gate the answer
This is the step that K3 makes non-negotiable.
K3's measured hallucination rate is higher than the model it replaces. On the real run, it produced a correct dependency chain and then, in the same breath, attributed a quote to "one engineer" that appears in none of the sources. The facts were right. The quote was invented.
QUOTED = re.compile(r'"([^"]{8,})"')
def citation_gate(answer: str, corpus: list[str]) -> tuple[bool, str]:
body = " ".join(corpus).lower()
for q in QUOTED.findall(answer):
if q.lower() not in body:
return False, f'quote not in sources: "{q[:32]}"'
return True, "ok"The gate checks every quoted string against everything the tools actually returned. The chain ships. The fabricated quote does not. With a model that hallucinates more, the gate is not a quality feature. It is the thing standing between the model and your users.
09. Cap the budget
The last step is the kill switch. A swarm multiplies cost across workers; a single agent multiplies it across turns, and K3 runs at maximum thinking effort by default, so its turns are not cheap.
state.tokens = estimate_tokens(state.messages)
if state.tokens > budget_tokens: # kill switch, not a wish
trace.append(Step(n, "halt", f"budget {budget_tokens} exceeded"))
return state, traceChecked at the top of every loop iteration, before the next call. On the real run, a generous budget let the task finish in three calls. A budget of forty tokens halted it after the first. The difference between a budget and a hope is that one of them cancels the run.
What the system actually does
I ran the whole thing on one question: what breaks if Redis goes down, and what keeps working.
=== the run ===
1 -> tool search 'what depends on redis' (2 hits)
2 -> tool search 'auth service dependencies' (2 hits)
3 == answer STOP quote not in sources: "a total company outage"
K3 calls: 3 tokens: 96 corpus: 4 chunksRead the third line. K3 did the research, assembled a real answer, and tried to ship a quote it made up. The system caught it at the gate, on step three, before a word reached the user. That is the whole difference between a prompt and a system: the prompt would have handed you the fabricated quote with total confidence.
Conclusion: the model was never the system.
Nine steps, three layers.
Notice how little of this is the model. K3 does the thinking in exactly one place, the call, and everything else is scaffolding you own and can inspect. That is the point of the whole series.
The leverage was never in a better prompt or a bigger model. It was in the system that decides what the model works on, checks what it produces, and holds the budget.
K3 is a good model to end on because it is powerful and it lies. A system built around it has to be honest about that, and the honesty is the gate on step eight and the ceiling on step nine. Point this same roadmap at a more reliable model and the scaffolding does not change. You just get to trust it a little more.
⚠️ The model was never the system. You are. Build the loop, gate the output, hold the budget, and stay the engineer.







