Kestrel is a runtime for coding agents. You give it a repo and a task. It starts a sandbox with a copy of the repo, runs your agent inside it, checks the diff against your tests and our evals, and opens a pull request when every check passes.
Install the SDK:
Then start a run:
import { Kestrel } from "@kestrel/sdk" ;
const kestrel = new Kestrel (); // reads KESTREL_API_KEY
const run = await kestrel.runs. create ({
repo: "acme/web-app" ,
task: "Fix the flaky checkout test" ,
});
const result = await run. wait ();
console. log (result.pullRequest?.url); from kestrel import Kestrel
kestrel = Kestrel() # reads KESTREL_API_KEY
run = kestrel.runs.create(
repo = "acme/web-app" ,
task = "Fix the flaky checkout test" ,
)
result = run.wait()
print (result.pull_request.url)
Agent run
One agent run: a task moves from the queue into a sandbox, a branch leaves the main line for it, the agent reads the repo and writes a diff, eight tests pass one by one, and only then is a pull request opened and merged back onto the main line.
87.5 || q.y < -4.7 || q.y >= 23.5) return 0.0;
float r = floor(q.y / 9.4 + 0.5);
float v = 0.15;
if (r < 0.5) v = q.x < 3.5 ? 1.05 : (q.x > 10.5 && q.x < 59.5 ? 0.7 : v);
else if (r < 1.5) v = q.x > 10.5 && q.x < 80.5 ? 0.24 : v;
else v = q.x > 10.5 && q.x < 52.5 ? 0.24 : v;
return v;
}
// A faint link: a soft band from a to b, at least a column wide and a cell tall, so it never falls between samples.
float lk(vec2 p, vec2 a, vec2 b, float k) {
vec2 ab = b - a;
float h = clamp(dot(p - a, ab) / dot(ab, ab), 0.0, 1.0);
vec2 d = (p - a - ab * h) / vec2(3.6, 4.8);
return k * (1.0 - sm(0.8, 1.05, length(d)));
}
vec2 bz(vec2 a, vec2 c, vec2 b, float u) { return mix(mix(a, c, u), mix(c, b, u), u); }
float field(vec2 p, float t) {
if (p.x < 18.0 || p.x > 702.0 || p.y < 18.0 || p.y > 442.0) return 0.0;
float ph = TAU * t / T;
float v = 0.0;
float gr = floor((p.y - 3.5) / 9.4 + 0.5); // the cell row this sample is on
// This run's clock: before 0.6 s the last run is still on screen, so its parts read the clock a cycle on and are
// torn down from 0.3 to 0.55 s.
float tt = t < 0.6 ? t + T : t;
float env = 1.0 - sm(T + 0.3, T + 0.55, tt);
float appear = sm(1.15, 1.25, tt) * env; // the repo, checked out into the sandbox
// The queue: the top task leaves at 0.45 s, the rest move up one, and a new task joins at the bottom.
float q = ease((t - 0.55) / 0.4);
for (int j = 1; j < 4; j++) v += card(p - vec2(32.0, R(9.0) + 47.0 * (float(j) - q)));
v += sm(0.95, 1.2, t) * card(p - vec2(32.0, R(24.0)));
// The task: waiting, carried into the sandbox's header (0.45 to 0.9 s), held there for the run.
if (t < 0.45) v += card(p - vec2(32.0, R(9.0)));
else if (t < 0.9) v += card(p - vec2(32.0, R(9.0)) - vec2(119.0, -28.2) * ease((t - 0.45) / 0.45));
v += (t >= 0.9 ? 1.0 : 1.0 - sm(0.3, 0.55, t)) * card(p - vec2(151.0, R(6.0)));
// Main: the trunk, a commit every 70 px, each earlier run's merge a small arch. After a merge the view follows main
// one commit to the left (0.55 to 0.95 s), and the merge's arch shows as its branch goes (0.3 to 0.55 s).
float X = -70.0 * ease((t - 0.55) / 0.4);
float old = sm(18.0, 150.0, p.x);
if (gr == 42.0 && p.x < 515.0 + X + 70.0 * ease((t - 4.05) / 0.13) + 1.0) v += 0.42 * old;
for (int k = 0; k < 8; k++) {
float x = 515.0 - 70.0 * float(k) + X;
v += old * (blob(p, vec2(x, R(42.0)), 9.5, 1.15) + blob(p, vec2(x, R(42.0)), 16.0, 0.2));
float e = (length((p - vec2(x - 35.0, R(42.0))) / vec2(35.0, 28.2)) - 1.0) * 26.0;
v += old * (k == 0 ? sm(0.3, 0.55, t) : 1.0) * 0.4 * exp(-e * e / 40.0) * step(p.y, 392.0);
}
// This run's merge commit lands at 4.18 s and flares.
v += sm(4.12, 4.2, t) * (blob(p, vec2(585.0 + X, R(42.0)), 9.5, 1.15) + blob(p, vec2(585.0 + X, R(42.0)), 16.0, 0.2));
v += blob(p, vec2(585.0 + X, R(42.0)), 16.0, 0.8 * exp(-pow((t - 4.24) / 0.1, 2.0)));
// This run's branch: up from main's head into the sandbox (0.95 to 1.15 s).
if (tt > 0.95) {
float tip = R(42.0) - 58.0 * ease((tt - 0.95) / 0.2);
if (abs(p.x - 445.0) < 1.0 && p.y > tip && p.y < 392.0) v += 0.45 * env;
if (tt < 1.15) v += blob(p, vec2(445.0, tip), 7.0, 1.2);
}
// Pushed from the test strip to the PR (3.5 to 3.72 s).
float pu = (tt - 3.5) / 0.22;
if (pu > 0.0) {
float tip = mix(473.0, 564.0, ease(pu));
if (gr == 31.0 && p.x > 469.0 && p.x < tip) v += 0.42 * env;
if (pu < 1.0) v += blob(p, vec2(tip, R(31.0)), 7.5, 1.25);
}
// Merged: from the PR down onto main (3.92 to 4.18 s).
float mu = (tt - 3.92) / 0.26;
if (mu > 0.0) {
vec2 A = vec2(599.0, 318.0); vec2 C = vec2(599.0, 380.0); vec2 B = vec2(528.0, 396.0);
float um = ease(mu);
float ln = 0.0;
for (int i = 0; i < 8; i++) {
float a = float(i) / 8.0;
if (a < um) ln = max(ln, lk(p, bz(A, C, B, a), bz(A, C, B, min(a + 0.125, um)), 0.42));
}
v += env * ln;
if (mu < 1.0) v += blob(p, bz(A, C, B, um), 7.5, 1.25);
}
// The PR card on a faint body, drawn row by row as it opens (3.72 s): title, branch into main, description, diffstat,
// five passed checks and the merge button, which flashes when pressed (3.88 s) and dims once merged.
float open = tt - 3.72;
if (open > 0.0 && p.x > 560.0 && p.x < 680.0 && gr >= 9.0 && gr <= 30.0) {
float c = floor((p.x - 564.0) / 7.0 + 0.5);
float pv = 0.15;
if (gr == 9.0) pv = c <= 12.0 ? 0.7 : pv;
else if (gr == 11.0) pv = c <= 5.0 || (c >= 7.0 && c <= 11.0) ? 0.42 : pv;
else if (gr == 13.0) pv = c <= 16.0 ? 0.24 : pv;
else if (gr == 14.0) pv = c <= 14.0 ? 0.24 : pv;
else if (gr == 15.0) pv = c <= 8.0 ? 0.24 : pv;
else if (gr == 17.0) pv = c <= 6.0 && mod(c, 2.0) < 0.5 ? 1.05 : (c == 8.0 ? 0.42 : pv);
else if (gr >= 19.0 && gr <= 27.0 && mod(gr, 2.0) > 0.5) {
float len = ((gr - 19.0) / 2.0 < 0.5 ? 12.0 : ((gr - 19.0) / 2.0 < 1.5 ? 10.0 : ((gr - 19.0) / 2.0 < 2.5 ? 14.0 : ((gr - 19.0) / 2.0 < 3.5 ? 8.0 : 11.0))));
pv = c == 0.0 ? 1.05 : (c >= 2.0 && c <= len + 1.0 ? 0.42 : pv);
} else if (gr >= 29.0 && c <= 12.0) pv = mix(0.75, 0.4, sm(4.0, 4.2, tt)) + 0.5 * exp(-pow((tt - 3.9) / 0.04, 2.0));
v += env * step(0.006 * (gr - 9.0), open) * pv;
}
// The sandbox's floor: faint grit while the sandbox exists (from the task's arrival to the teardown).
if (p.x > 142.0 && p.x < 478.0 && p.y > 42.0 && p.y < 334.0) v += 0.17 * sm(0.9, 1.05, tt) * env * step(0.86, hash(floor(p / vec2(7.0, 9.4)) + 5.3));
// The repo tree: a marker per row (a folder denser than a file), the name, and nesting guides. A row lights as the
// agent reads it and stays read.
if (p.x > 146.0 && p.x < 232.0 && gr >= 11.0 && gr <= 33.0 && mod(gr, 2.0) > 0.5) {
float i = (gr - 11.0) / 2.0;
float lvl = (i < 0.5 ? 0.0 : (i < 1.5 ? 1.0 : (i < 2.5 ? 2.0 : (i < 3.5 ? 2.0 : (i < 4.5 ? 2.0 : (i < 5.5 ? 1.0 : (i < 6.5 ? 2.0 : (i < 7.5 ? 2.0 : (i < 8.5 ? 1.0 : (i < 9.5 ? 0.0 : (i < 10.5 ? 1.0 : 0.0)))))))))));
float dir = (i < 0.5 ? 1.0 : (i < 1.5 ? 1.0 : (i < 2.5 ? 0.0 : (i < 3.5 ? 0.0 : (i < 4.5 ? 0.0 : (i < 5.5 ? 1.0 : (i < 6.5 ? 0.0 : (i < 7.5 ? 0.0 : (i < 8.5 ? 0.0 : (i < 9.5 ? 1.0 : (i < 10.5 ? 0.0 : 0.0)))))))))));
float len = (i < 0.5 ? 5.0 : (i < 1.5 ? 4.0 : (i < 2.5 ? 6.0 : (i < 3.5 ? 4.0 : (i < 4.5 ? 5.0 : (i < 5.5 ? 3.0 : (i < 6.5 ? 6.0 : (i < 7.5 ? 3.0 : (i < 8.5 ? 5.0 : (i < 9.5 ? 4.0 : (i < 10.5 ? 6.0 : 4.0)))))))))));
float c = floor((p.x - 151.0) / 7.0 + 0.5) - 2.0 * lvl;
float ti = 1.25 + 0.7 * i / 11.0;
float rv = 0.0;
if (c == 0.0) rv = dir > 0.5 ? 0.7 : 0.4;
else if (c >= 2.0 && c <= len + 1.0) rv = 0.22 + 0.24 * sm(ti - 0.03, ti, tt);
v += appear * (rv + (rv > 0.0 ? 0.55 * exp(-pow((tt - ti) / 0.05, 2.0)) : 0.0));
}
if (abs(p.x - 151.0) < 1.0 && ((gr >= 12.0 && gr <= 27.0) || (gr >= 30.0 && gr <= 31.0))) v += 0.17 * appear;
if (abs(p.x - 165.0) < 1.0 && ((gr >= 14.0 && gr <= 19.0) || (gr >= 22.0 && gr <= 25.0))) v += 0.17 * appear;
// The diff: context and removed lines are there from the checkout; the agent marks the removed ones and types the
// added ones in, one after another.
if (p.x > 293.0 && p.x < 470.0 && gr >= 11.0 && gr <= 25.0 && mod(gr, 2.0) > 0.5) {
float j = (gr - 11.0) / 2.0;
float ty = (j < 0.5 ? 0.0 : (j < 1.5 ? 2.0 : (j < 2.5 ? 1.0 : (j < 3.5 ? 1.0 : (j < 4.5 ? 1.0 : (j < 5.5 ? 0.0 : (j < 6.5 ? 1.0 : 1.0)))))));
float ind = (j < 0.5 ? 0.0 : (j < 1.5 ? 1.0 : (j < 2.5 ? 1.0 : (j < 3.5 ? 2.0 : (j < 4.5 ? 2.0 : (j < 5.5 ? 1.0 : (j < 6.5 ? 1.0 : 0.0)))))));
float len = (j < 0.5 ? 12.0 : (j < 1.5 ? 15.0 : (j < 2.5 ? 17.0 : (j < 3.5 ? 13.0 : (j < 4.5 ? 10.0 : (j < 5.5 ? 14.0 : (j < 6.5 ? 18.0 : 5.0)))))));
float wj = 2.2 + 0.55 * j / 7.0;
float wr = sm(wj - 0.02, wj + 0.02, tt) * env;
float c = floor((p.x - 312.0) / 7.0 + 0.5) - 2.0 * ind;
float dv = 0.0;
if (abs(p.x - 298.0) < 1.0) dv = ty > 1.5 ? 0.7 * wr : (ty > 0.5 ? 1.05 * wr : 0.0);
else if (c >= 0.0 && c < len) {
if (ty < 0.5) dv = 0.22 * appear;
else if (ty > 1.5) dv = 0.22 * appear + 0.2 * wr;
else dv = 0.7 * env * step(c, len * clamp((tt - wj) / 0.07, 0.0, 1.0) - 0.5);
}
v += dv + (dv > 0.0 ? 0.4 * exp(-pow((tt - wj - 0.04) / 0.05, 2.0)) : 0.0);
}
// The test strip: eight slots; each test's bar rises while it runs (one after another from 2.8 s) and goes solid,
// its top densest, once it passes.
if (p.x > 308.0 && p.x < 470.0 && gr >= 29.0 && gr <= 33.0) {
float sx = floor((p.x - 312.0) / 7.0 + 0.5);
float i = floor(sx / 3.0);
if (sx - 3.0 * i < 1.5) {
float h = (i < 0.5 ? 3.0 : (i < 1.5 ? 2.0 : (i < 2.5 ? 4.0 : (i < 3.5 ? 3.0 : (i < 4.5 ? 2.0 : (i < 5.5 ? 5.0 : (i < 6.5 ? 3.0 : 2.0)))))));
float f = clamp((tt - 2.8 - 0.075 * i) / 0.09, 0.0, 1.0);
float lv = 33.0 - gr; // 0 on the baseline
float bar = lv < h * f - 0.01 ? (f < 1.0 ? 0.5 : (lv > h - 1.5 ? 1.05 : 0.7)) : 0.0;
v += max(0.15 * appear, bar * env);
}
}
// The agent, between the repo and the diff: a bright rim, an inner ring, a speckled floor and a dense core that
// churns, brighter while it works, with a mark turning on its rim. Its beam goes to what it is working on: the task
// (0.95 to 1.15 s), each repo row in turn (1.25 to 1.95 s), each diff line in turn (2.2 to 2.75 s).
vec2 ac = vec2(263.0, 182.0);
float work = sm(0.9, 1.05, t) * (1.0 - sm(2.8, 3.0, t));
float d = length(p - ac) / 30.0;
float inside = 1.0 - sm(0.9, 1.0, d);
float churn = noise(p * 0.11 + 1.6 * vec2(cos(ph), sin(ph)));
v += (0.4 + 0.12 * work) * exp(-pow(d - 1.0, 2.0) / 0.012);
v += inside * (0.3 * exp(-pow(d - 0.62, 2.0) / 0.006) + 0.14);
v += blob(p, ac, 17.0, (1.05 + 0.3 * work) * (0.85 + 0.6 * (churn - 0.5)));
v += blob(p, ac + 30.0 * vec2(cos(2.0 * ph), sin(2.0 * ph)), 5.5, 0.55 + 0.4 * work);
vec2 aim = vec2(0.0);
float beam = 0.0;
if (tt > 0.95 && tt < 1.15) { aim = vec2(236.0, R(7.0)); beam = sm(0.95, 0.99, tt) * (1.0 - sm(1.11, 1.15, tt)); }
else if (tt > 1.25 && tt < 1.95) { aim = vec2(232.0, mix(R(11.0), R(33.0), (tt - 1.25) / 0.7)); beam = sm(1.25, 1.29, tt) * (1.0 - sm(1.91, 1.95, tt)); }
else if (tt > 2.2 && tt < 2.75) { aim = vec2(292.0, mix(R(11.0), R(25.0), (tt - 2.2) / 0.55)); beam = sm(2.2, 2.24, tt) * (1.0 - sm(2.71, 2.75, tt)); }
if (beam > 0.0) v += beam * step(1.0, d) * (lk(p, ac + 30.0 * normalize(aim - ac), aim, 0.45) + blob(p, aim, 5.0, 0.6));
return v;
}]]>
One run from start to finish: the task leaves the queue for a sandbox, the agent reads the repo and writes a diff, every test passes, and only then does a pull request open and merge.
The four parts you will work with:
Sandbox : a sealed Linux VM made for one task. The repo goes in, only the diff comes out, and the sandbox is wiped when the task ends.
Memory : what an agent works out about your repo, kept between runs, so the next run starts where the last one stopped.
Eval gate : your tests and our evals, run in order on every diff. A failing diff goes back to the agent with the failure attached.
Traces : every file read, tool call and test in a run, on one time axis.
Quickstart : your first run, step by step, in about five minutes.
Guides : longer walkthroughs for one job, like fixing flaky tests or running Kestrel from an issue label.
SDK Reference : every method in the TypeScript and Python SDKs, and every CLI command.
Note Working with your own coding agent? These docs are written for agents too. Every page is served as Markdown at the same address with .md on the end, and /llms.txt lists them all.