A programming language
anyone can read.
On the left, the function you’d write today. On the right, the same thing in mdlang — the parameters become args, the return type becomes output, the imports become tools, and the body becomes English.
$ npx mdlang initRead the docsWrites a config, a system prompt, and an example action that runs as-is.
import { hashPassword } from "@/lib/crypto";import { sendEmail } from "@/lib/mail";import { db, users } from "@/lib/db"; type Args = { email: string; password: string; plan?: string;}; type Result = { id: string; welcomeSent: boolean;}; export async function createUser({ email, password, plan = "free",}: Args): Promise<Result> { const passwordHash = await hashPassword(password); const [user] = await db .insert(users) .values({ email, passwordHash, plan }) .returning({ id: users.id }); const welcomeSent = await sendEmail(email, "welcome"); return { id: user.id, welcomeSent };}---name: create-userdescription: Create a user account.mcpTools: - Postgres: - insertcustomTools: - hashPassword - sendEmailargs: - name: email type: string required: true - name: password type: string required: true - name: plan type: string default: freeoutput: type: object items: - name: id type: string - name: welcomeSent type: boolean---Hash the password, insert the user, thensend the welcome email.Same arguments, same return type, same dependencies. The difference is the body.
MIT · no account · any LangChain model · Node 20+
The idea
Don't generate the code. Run the description.
The latent space is the runtime. YAML wires it up; Markdown is the algorithm.
An action is a Markdown file. Frontmatter declares the contract — what goes in, what comes back, which tools it may touch. The body is the algorithm, in the language you’d use to explain it to a colleague. Nothing is generated on your behalf and left for you to maintain: the file is the implementation, and it stays readable for exactly as long as English does.
---name: create-userdescription: Create a user account.mcpTools: - Postgres: - insertcustomTools: - hashPassword - sendEmailargs: - name: email type: string required: true - name: password type: string required: true - name: plan type: string default: freeoutput: type: object items: - name: id type: string - name: welcomeSent type: boolean---Hash the password, insert the user, thensend the welcome email.const { id, welcomeSent } = await action("create-user", { email: "ada@example.com", password: form.password,});id and welcomeSent are typed from the file. A name that doesn’t exist won’t compile, and a missing password is rejected before the model is ever called.
The real bill
Writing it was never the expensive part.
Four costs that arrive after the feature ships, none of which are about how good the code was on the day it was written.
CVEs
A vulnerability lands three levels deep in your dependency tree. Nothing you wrote is wrong. You bump the version, something breaks, you refactor around it, you redeploy.
Dependency sync and EOL
A package reaches end of life. Your code still works perfectly. You do the work anyway, on their schedule.
The engineer who left
People move on. That's normal and it isn't going to stop. What leaves with them is the only complete mental model of why the code does what it does.
Logic only the author can read
The person who requested the rule can't check the rule. Every clarification is a ticket, and every change is a deploy.
An assistant that writes code faster does not help with any of these. It makes all four arrive sooner.
What stays code
The model picks the steps. Your code does the work.
You need to understand the critical ten percent, not the whole application.
The model never does arithmetic, moves money, or writes to your database. It decides which tool runs and in what order. The work itself happens in customTools — ordinary TypeScript you wrote, that you unit-test, that behaves identically every time it is called.
That is the trade. Sequencing and judgment move into Markdown, where they are readable. Anything that has to be exact stays in code, where it is testable. The surface you have to hold in your head shrinks to the part that actually matters.
It can't call what it wasn't given
A per-action allowlist in frontmatter. There is no ambient tool registry to fall back on.
It can't be called wrong
Arguments are validated against the generated schema before any agent starts, so a bad payload costs nothing.
It can't return the wrong shape
Output is checked against the declared schema. A response that doesn't match fails rather than propagates.
It can't name a tool that doesn't exist
Codegen connects to your MCP servers and skips any action referencing a missing tool, with a warning.
The dependency layer
A CVE you don't have to do anything about.
When a capability comes from an MCP server instead of an npm package, the provider patches it and you're patched. There's no version to bump, no breaking change to absorb, no transitive tree in your lockfile, and no end-of-life date that becomes your problem on someone else's schedule.
Patching stops being a refactor you schedule. A fix lands upstream and you are running it — no version bump, no breaking change to absorb, no dependency graph to re-resolve. The capability has a maintainer, and it isn’t you.
This moves the problem, it doesn’t delete it. An MCP server is still software someone maintains, with its own vulnerabilities and its own uptime. You are trading a pinned in-process dependency for a service boundary — a real trade, right more often than not, and still a trade.
The contract
Predictable results from a model that isn't.
An action is a contract. The Markdown says what goes in, what comes out, and what the agent may touch — and the generated client holds it to all three.
A signature, not a prompt
The generated client knows create-user takes an email and a password and returns an id. Autocomplete works; a renamed field is a type error, not a 3am surprise.
Checked before the model runs
Call it without an email and it throws immediately, naming the field. The request never reaches your provider and never costs a token.
Tools that can't drift
create-user can insert rows and send mail. It cannot drop a table, because dropping isn't in its frontmatter and there is no ambient registry to fall back on.
One file, whole definition
Instructions, inputs, outputs and permissions sit together and version together. There's no second place to look when you need to know what it does.
Context variables
The password never reaches the model.
The model works with the names of your values. The runtime supplies the values.
Follow one create-user run. Arguments enter as $-prefixed variables; real values are substituted the instant before each tool call executes.
hashPassword({ raw: "$password" })hashPassword({ raw: "correct-horse-battery…" })The password enters the run as a name. The model routes it without ever reading it.
ok. captured: $hashedPasswordcontext: { $hashedPassword: "$2b$10$9x4KpQr7v…" }contextOutput captures the hash. The agent is told a value exists, not what it is.
insert({ into: "users", values: { email: "$email", password_hash: "$hashedPassword",} })insert({ into: "users", values: { email: "ada@example.com", password_hash: "$2b$10$9x4KpQr7v…",} })Real values are substituted the moment before the call executes.
Secrets stay out of provider logs
A value that never enters the transcript can't be retained by anyone downstream.
Token cost stops tracking data size
Routing a short id and routing a 40KB document cost the same, because the model sees the same thing.
References resolve before you get them
Any $ left in the final result is substituted before action() returns, so a caller never sees a variable name.
In practice
Put it behind an endpoint.
An action is ordinary async TypeScript, so it drops into a route handler with nothing around it. A bad body is a validation error before an agent starts, and isActionName narrows an arbitrary string when you want to dispatch by name.
app.post("/users", async (req, res) => { const { id } = await action("create-user", { email: req.body.email, password: req.body.password, }); res.status(201).json({ id });});Where it fits
Work that needs judgment, not just logic.
Anything you'd describe to a competent colleague in three sentences, but would spend two days encoding as branches.
create-userOnboarding and account setup
Hash, insert, mail, enrich. The tedious multi-step work that takes an afternoon to hardcode and one sentence to describe.
triage-ticketSupport triage
Read the ticket, pick a queue, set a priority. Comes back as { queue, priority, reason } your router can switch on.
extract-invoiceDocument extraction
Text in, typed fields out. The document travels as a context variable, so token cost doesn't scale with how long it is.
classify-leadEnrichment and classification
Look it up, judge it, label it, write it back — with the action scoped to exactly the two tools that work needs.
Honest limits
Where this is the wrong tool.
Four cases where you should write the code instead. This list will get longer as people find things; it won't get shorter.
Anything that must be exact
Pricing, tax, payouts, permission checks. Put these in customTools — real code, real tests. The model can decide when to call them. It should never be the thing computing them.
Hot paths
Every call builds an agent, connects to the MCP servers that action declares, and tears them down. Fine at ordinary request volume, wrong for something serving thousands a second.
Work with no judgment in it
If the logic is a pure function of its inputs and always has been, a function is cheaper, faster and more reliable. mdlang earns its cost where a decision has to be made.
Latency-sensitive user paths
An agent run is model latency plus tool latency. If a user is waiting on it synchronously, measure before you commit to it.
mdlang is early. The API is stable enough to build on and not so stable that it won’t change. It is MIT, it runs with no account, and the worst case is deleting the dependency and keeping your Markdown.
The alternatives
What you'd do instead.
Three honest options, and where each one stops working.
Write the code
With or without an assistant. Total control, complete determinism, and you own every line forever — including the parts generated in an afternoon by a tool that won't remember writing them. This is the real alternative and it is often the right one. mdlang is a bet that for the judgment-shaped part of an application, ownership costs more than it's worth.
An agent framework
LangGraph, Mastra, a hand-rolled AI SDK loop. Same execution model, assembled by you. mdlang compiles down to the same LangChain agents and the same MCP tools — it's the schema and the codegen around them, not a runtime to learn instead of one.
A dedicated LLM-function DSL
Closest in spirit, and a reasonable choice. The trade is a new language and its toolchain against Markdown the whole team already reads. mdlang also assumes a tool-calling loop over MCP servers rather than a single structured call, so multi-step work is the default rather than the extension.
If you’re wondering about Agent Skills
A Skill teaches a model how to work. An action is something your service calls.
They look alike on disk and they solve different problems. A Skill is guidance a model picks up when it judges it relevant. An action is a function — a name, a signature, a fixed set of tools, and a caller waiting on the result.
{ id: string; welcomeSent: boolean }Questions
Before you install it.
Isn't an LLM running business logic non-deterministic?
The model chooses which tool runs and in what order. It doesn’t compute your results — your customTools do, and those are ordinary tested code. Arguments are validated before a run starts, output is checked against the declared shape, and an action can only reach the tools its frontmatter names. Put anything that must be exact in a tool and the non-deterministic part is sequencing — which is the part you wanted judgment in anyway.
Aren't MCP servers just dependencies with extra steps?
They’re still software someone maintains. What changes is that patching stops being your refactor: there’s no version pinned in your lockfile, no transitive tree, and no end-of-life date you inherit. You’re trading an in-process dependency for a service boundary. That’s a real trade with real downsides, and usually the better one.
What does a run cost, and how do I know it works?
One agent run, minus the payloads — context variables keep large values out of the transcript, so a long document isn’t billed as prompt. Set pricing in your config and every run logs its own tokens and cost. npx mdlang dataset synthesizes inputs per action and runs them with tools mocked, which gives you material to evaluate against before you trust one.
Can I use this where I can't send data to a provider?
That’s what context variables are for. Arguments enter the run as $nameand the runtime substitutes real values into tool calls the moment before they execute — so the values are never in a request body and never in a provider’s logs. It doesn’t make mdlang a compliance product, but it removes the reason most teams can’t try one.
Do I need to know LangChain or MCP?
Not to start. npx mdlang init writes a config, a system prompt and an example action that runs as-is. MCP only shows up when you want an action to reach a real system, and then it’s a server name and a list of tools.
Which models can I use?
Any LangChain chat model — OpenAI, Anthropic, Google, Bedrock, or one you host. It’s a single line in mdlang.config.ts, and switching providers changes nothing in your action files.
Am I locked into anything?
The SDK is MIT on npm and works with no account. Your actions are Markdown you already own — the worst case is deleting the dependency and keeping the files.
Isn't this just prompts in a folder?
Plus a validated contract, a per-action tool allowlist checked against live servers at build time, a typed client, and payload isolation. The build step is the point: it’s what turns a folder of text into something your compiler and your call sites can hold to account.
Start with one action.
npx mdlang init writes a config, a system prompt, and an example you can run straight away.
Node 20+ · ESM · peers @langchain/core and zod · MIT