Loops? Graphs? Prolog!
Step by step guide to building an agent with DeepClause.
So, apparently people are discussing how to orchestrate agents not just with loops now, but also graphs:
This framing is somewhat confusing to me. Or at least the timing of it. Didn’t we have LangGraph already since like…forever? What have all these other agent frameworks been doing? Or is this about letting your agent write the prompts for other agents? Who knows…
What I do know is that whatever loop or graph-based orchestration you desire, it should be easily implementable using an actual Turing-complete language. So why not try Prolog!
Prolog itself (or rather modern implementations such as SWI or Scryer Prolog) might not be exactly the right choice to do this of course. This is where the DeepClause project comes in. It introduces a language called DML: a Prolog dialect purpose-built for programming AI agents. You get LLM calls as native predicates, automatic backtracking across model invocations and agent memory, typed outputs, and tool orchestration — all in a few lines of declarative code that get executed by TypeScript and WASM based runtime (it actually uses SWI Prolog through WASM).
So let’s try it out and build something real: a vulnerability scanner agent that analyzes a codebase, finds security issues, and reports them. We’ll start dead simple and layer on capabilities.
The Simplest Possible Agent
First, install and configure DeepClause:
npm install -g deepclause-sdk
deepclause init #this creates .deepclause/
deepclause --set-model [your favorite model, e.g. openai:gpt-5.4-mini]Having completed our setup, let’s start by defining our agent. Open a file in the current directory (where your .deepclause folder is), name it “vuln-scanner.dml” and add the following code:
agent_main :-
system(”You are a senior security researcher.
You specialize in finding vulnerabilities
in source code.“),
task(”Analyze common web application patterns and
list the top 5 vulnerability categories
you would look for during a security audit.“),
answer(“Analysis complete.”).What does this mean?
Every DML program starts with
agent_main. This is the agent entry point.system/1sets the LLM’s persona. It goes into the conversation as a system message — persistent context that shapes every subsequent call.task/1sends a request to the model. The LLM thinks, responds, and execution continues. This will trigger a full blown agent loop. If we had defined any tools for our agent, they might get used here as well (see next example for more details).answer/1emits the final result and commits — no backtracking happens after this. The program is done.
Run it:
deepclause run vuln-scanner.dmlDepending on what model you chose, this will produce a nice list of OWASP categories or similar (use —stream and —verbose options to better understand what’s going on). Useful as a starting point, but not exactly a scanner. It’s just talking to itself. Let’s make it interactive.
Adding User Input
Now, let’s add a tool that the LLM can use to talk to users. In DML we defined LLM-tools with the tool/2 predicate — a name, a description (shown to the LLM), and a body that does the actual work:
% Tool: Ask the user a question
tool(ask(Prompt, Response),
“Ask the user a question and get their response”) :-
exec(ask_user(prompt: Prompt), Result),
Response = Result.user_response.
agent_main :-
system(”You are a senior security researcher.
You specialize in finding vulnerabilities
in source code.“),
task(”Ask the user what codebase or file they want
to analyze for security vulnerabilities.
Store their response in Target.“, Target),
task(”Based on the target ‘{Target}’, outline a
security audit plan. List what you would check
and why. Store the plan in Plan.”, Plan),
output(Plan),
answer(“Audit plan generated.”).tool/2 makes a predicate visible to the LLM as a tool. When we tell the model to “ask the user” in a task(), it can call our ask tool — DML routes that to the exec/2 call under the hood, which invokes the registered ask_user external tool (things callable through the exec predicate are actually defined in DeepClause’s typescript layer, which maybe extended through the DeepClause SDK). The response flows back into the Prolog variable “Response”. The key insight: tools defined with tool/2 are available to the LLM during any task() call. The model decides when to use them.
task/2 (with two arguments) is where DML gets interesting. The second argument is an output variable. We instruct the model to “ask the user” and “store their response in Target” — the LLM calls the ask tool, gets the user’s answer, and the runtime binds it to Target. You reference the variable name in the prompt itself, and the runtime extracts it. This is how data flows between steps: not through callbacks or shared mutable state, but through Prolog unification.
output/1 emits progress to the caller without ending execution. Unlike answer/1, it doesn’t commit — the program keeps running.
The model now asks the user what to scan, gets a target, and produces a plan. Better. But a plan without execution is just a document.
Running Shell Commands
Let’s give our agent hands. The vm_exec built-in runs shell commands in a (kind of) sandboxed environment (by default this is via bwrap on linux, sandbox-exec on macos, plus some fallbacks in case that’s not available):
% Tool: Ask the user a question
tool(ask(Prompt, Response),
“Ask the user a question and get their response”) :-
exec(ask_user(prompt: Prompt), Result),
Response = Result.user_response.
% Tool: Run a shell command in the sandbox
tool(run_shell(Command, Output),
“Run a shell command and return stdout”) :-
exec(vm_exec(command: Command), Result),
get_dict(stdout, Result, Output).
% Tool: Read a file
tool(read_file(Path, Content),
“Read the contents of a file”) :-
format(string(Cmd), “cat ‘~w’”, [Path]),
exec(vm_exec(command: Cmd), Result),
get_dict(stdout, Result, Content).
agent_main :-
system(”You are a senior security researcher performing
a hands-on code audit. You can run shell commands
and read files. Be thorough and methodical.”),
task(”Ask the user for the directory to scan.
Store their answer in Target.“, Target),
output(“Scanning codebase...”),
task(”Explore the directory ‘{Target}’:
1. List all source files using run_shell
2. Identify the languages and frameworks used
3. Read key files that are likely to contain
security-relevant code (auth, input handling,
database queries, API endpoints)
Store a summary of what you found in Recon.“, Recon),
output(“Analyzing for vulnerabilities...”),
task(”Based on your reconnaissance: {Recon}
Read the relevant source files and analyze them
for security vulnerabilities. Look for:
- SQL injection
- XSS (cross-site scripting)
- Authentication/authorization flaws
- Hardcoded secrets or credentials
- Insecure deserialization
- Path traversal
- Command injection
For each finding, note the file, line, and a
brief explanation.
Store your findings in Findings.“, Findings),
output(“Generating report...”),
task(”Write a concise security report based on these
findings: {Findings}
Format as Markdown with severity ratings
(Critical/High/Medium/Low) for each issue.
Include remediation advice.
Store the report in Report.“, Report),
output(Report),
answer(“Security audit complete.”).Now the agent actually does things. It runs ls, find, cat — reads source files, greps for patterns, and applies security expertise to real code. The LLM chooses which tools to use and when, but the control flow — the sequence of recon → analysis → reporting — is deterministic. Defined in Prolog. No prompt engineering gymnastics to keep the model on track.
Notice how tool definitions are just Prolog clauses. read_file is syntactic sugar built on run_shell via format/3. You can compose tools arbitrarily, add validation logic, error handling — it’s all just Prolog.
Typed Outputs and Deterministic Branching
Here’s where DML earns its keep. So far, we’ve been treating LLM outputs as opaque strings. But what if we need structured decisions?
DML supports type-safe output variables. Wrap a variable in integer(), boolean(), list(string()), or object() and the runtime enforces the type with schema validation — no more parsing JSON from markdown code fences.
Let’s use this to build a proper triage system:
% --- Tool Definitions (same as before) ---
tool(ask(Prompt, Response),
“Ask the user a question and get their response”) :-
exec(ask_user(prompt: Prompt), Result),
Response = Result.user_response.
tool(run_shell(Command, Output),
“Run a shell command and return stdout”) :-
exec(vm_exec(command: Command), Result),
get_dict(stdout, Result, Output).
tool(read_file(Path, Content),
“Read the contents of a file”) :-
format(string(Cmd), “cat ‘~w’”, [Path]),
exec(vm_exec(command: Cmd), Result),
get_dict(stdout, Result, Content).
% --- Severity-based response ---
handle_findings(Count, _) :-
Count =:= 0,
answer(“No vulnerabilities found. The codebase looks clean.”).
handle_findings(Count, Findings) :-
Count > 0, Count =< 3,
task(”Write a brief security advisory for these
low-count findings: {Findings}
Store the advisory in Advisory.“, Advisory),
answer(Advisory).
handle_findings(Count, Findings) :-
Count > 3,
output(“⚠ High number of findings — running deep analysis...”),
task(”These findings need detailed remediation plans: {Findings}
For each vulnerability:
1. Explain the attack vector
2. Show a proof-of-concept (safe, illustrative)
3. Provide the exact code fix
Store the deep analysis in DeepReport.“, DeepReport),
task(”Write an executive summary of this security audit.
Total issues: {Count}. Store in Summary.”, Summary),
output(Summary),
answer(DeepReport).
% --- Main ---
agent_main :-
system(”You are a senior security researcher.
Be precise about vulnerability counts.“),
task(”Ask the user which directory to audit
using the ask tool. Store their answer in Target.”,
Target),
output(“Scanning...”),
task(”Scan ‘{Target}’ for source files and read the
security-relevant ones. Analyze for vulnerabilities.
Return the total number of distinct vulnerabilities
found in Count.“,
integer(Count)),
task(”List each vulnerability with file, line number,
and category. Store as Findings.”,
list(string(Findings))),
format(string(Msg),
“Found ~w potential vulnerabilities.”, [Count]),
output(Msg),
handle_findings(Count, Findings).Let’s trace what happens:
integer(Count)— the LLM must return a number. Not “about five” or “several.” An integer. The runtime automatically validates this with a dynamically generated Zod schema and will retry if the model returns garbage.list(string(Findings))— the findings come back as a proper list of strings. This gives you a Prolog list you canlength/2,member/2, or iterate over.handle_findings/2uses Prolog’s multiple clause definitions (so this would correspond to a few if or switch statements in imperative languages).Zero findings → clean bill of health
1–3 findings → brief advisory
4+ findings → deep analysis with PoCs and fixes
Summary
In about 60 lines of DML, we built a vulnerability scanner that:
Talks to the user
Explores a real filesystem
Reads and analyzes source code
Returns typed, validated results
Branches behavior based on structured LLM output
Has automatic retry semantics via backtracking
What is important here is that the above steps will be followed no matter what. Of course, you could specify all of the above DML code in a markdown file. But would you have a 100% guarantee that your model would always follow your markdown instructions?


