22. Code pipelines
Point a coding agent at a git repository and ask for a change. Maitento clones the repository into a sandbox of its own, runs the coding agent, commits its work and pushes a branch you can open a pull request from. Then you’ll make the pipeline check the work: after the change, a second agent runs the tests, and a small hook decides what happens next. If the tests fail, the work goes back to the developer with the failure, until they pass.
You’ll learn
- code pipelines: AI coding agents (Claude Code, Codex and others) on your git repository
- engines, agents, phases and steps, and how a pipeline’s work reaches your repository
- hooks: code that runs after a phase and decides what the pipeline does next
- a dynamic flow: a loop the hook adds while the pipeline runs, with a limit
New concepts: code pipelines · Builds on: 16. Apps (hooks are written in the same language) and 21. Sandboxes · Time: about 30 minutes, most of it waiting: each step is a coding session of a minute or more · Cost: a few cents per run on DeepSeek · Needs: a git repository you can push to over
https, and a token for it
1. A repository to work on
Section titled “1. A repository to work on”Push the tiny Python project in examples/22-code-pipelines/sample-repo
to a new repository of your own, on GitHub or anywhere else that serves git over https, with main as its branch.
Then make a token that can push to that repository only. On GitHub, use a fine-grained token with Contents: Read and
write.
Set two environment variables for the scripts: REPO_URL to the repository’s https address, and
REPO_CREDENTIALS to <user>:<token> (for GitHub, x-access-token:<token>).
Done when: your repository’s main branch has greet.py, test_greet.py and README.md.
2. The project
Section titled “2. The project”A code pipeline is a folder under code-pipelines/, named after the pipeline, with a pipeline-definition.json.
This one has one agent and one step:
{ "name": "add-feature", "durationAutoTermination": "00:30:00", "promptInputs": [ { "name": "request", "description": "The change to make, in plain words", "isRequired": true } ], "agents": [ { "name": "developer", "defaultEngine": "DeepSeek", "role": "Fullstack" } ], "phases": [ { "name": "development", "steps": [ { "name": "implement", "agentName": "developer", "prompt": "@prompt(implement)", "isCommitable": true } ] } ]}- An agent is a coding tool with a role. Its
defaultEnginesays which tool:DeepSeekis Codex CLI driven by DeepSeek’s models, on your DeepSeek model key. - A phase is a group of steps; phases run in order of their
dependsOn. - A step is one prompt to one agent.
isCommitablemeans its changes are committed and pushed when it succeeds.
"@prompt(implement)" is replaced with prompts/implement.md when you deploy, and %%input:request%% in it is the
run input called request:
Make this change to the repository:
%%input:request%%
Keep the change small and focused. Add or update a test if the project has tests.Done when: you can say which file the step’s prompt comes from.
3. Run it
Section titled “3. Run it”Run the scripts from examples/22-code-pipelines/typescript, python or csharp, as in
tutorial 1:
import { Maitento } from "@maitento/sdk";
// The repository to work on, and credentials that can push to it, e.g. "x-access-token:<github token>".const url = process.env.REPO_URL;const credentials = process.env.REPO_CREDENTIALS;if (!url || !credentials) throw new Error("Set REPO_URL and REPO_CREDENTIALS first.");
const mt = new Maitento();await mt.deploy("../maitento");
const run = await mt.pipelines.run("add-feature", { inputs: { request: "Add a --shout flag that prints the greeting in capital letters, with a test for it." }, repos: [{ type: "http", name: "app", url, credentials, branchSource: "main", branchWork: "maitento/add-shout-flag", }],});console.log(`Pipeline ${run.status}. The work is on the maitento/add-shout-flag branch.\n`);
const diff = await mt.pipelineRuns.rawDiff(run.id);console.log(diff?.diffRaw ?? "(no changes)");import osimport sys
from maitento import Maitento
# The repository to work on, and credentials that can push to it, e.g. "x-access-token:<github token>".url = os.environ.get("REPO_URL")credentials = os.environ.get("REPO_CREDENTIALS")if not url or not credentials: sys.exit("Set REPO_URL and REPO_CREDENTIALS first.")
mt = Maitento()mt.deploy("../maitento")
run = mt.pipelines.run( "add-feature", inputs={"request": "Add a --shout flag that prints the greeting in capital letters, with a test for it."}, repos=[{ "type": "http", "name": "app", "url": url, "credentials": credentials, "branch_source": "main", "branch_work": "maitento/add-shout-flag", }],)print(f"Pipeline {run.status}. The work is on the maitento/add-shout-flag branch.\n")
diff = mt.pipeline_runs.raw_diff(run.id)print(diff.diff_raw if diff else "(no changes)")#:package Maitento.Sdk@26.10.0
using Maitento.Entities.CodePipelines.Process;using Maitento.Entities.CodePipelines.WebApi;using Maitento.Entities.Database;using Maitento.Sdk;
// The repository to work on, and credentials that can push to it, e.g. "x-access-token:<github token>".var url = Environment.GetEnvironmentVariable("REPO_URL");var credentials = Environment.GetEnvironmentVariable("REPO_CREDENTIALS");if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(credentials)) throw new InvalidOperationException("Set REPO_URL and REPO_CREDENTIALS first.");
await using var mt = MaitentoClient.CreateFromEnvironment();await mt.DeployAsync("../maitento");
var request = "Add a --shout flag that prints the greeting in capital letters, with a test for it.";var run = await mt.CodePipelines.RunAsync("add-feature", new CodePipelineProcessStartRequest{ Inputs = [new NameValuePair("request", request)], Repos = [ new CodePipelineGitHttpRepo { Name = "app", Url = url, Credentials = credentials, BranchSource = "main", BranchWork = "maitento/add-shout-flag", }, ],});Console.WriteLine($"Pipeline {run.Status}. The work is on the maitento/add-shout-flag branch.\n");
var diff = await mt.CodePipelineProcesses.GetDiffRawAsync(run.Id);Console.WriteLine(diff?.DiffRaw ?? "(no changes)");A run takes a minute or two:
Pipeline Finished. The work is on the maitento/add-shout-flag branch.
diff --git a/greet.py b/greet.py--- a/greet.py+++ b/greet.py@@ -2,15 +2,17 @@ import argparse
-def greeting(name: str) -> str:- return f"Hello, {name}!"+def greeting(name: str, shout: bool = False) -> str:+ message = f"Hello, {name}!"+ return message.upper() if shout else message …+ parser.add_argument("--shout", action="store_true", help="print the greeting in capital letters")…diff --git a/test_greet.py b/test_greet.py…+def test_greeting_shout():+ assert greeting("Ana", shout=True) == "HELLO, ANA!"Maitento cloned your repository into a sandbox, created the branch maitento/add-shout-flag from main, ran the
coding agent with the prompt, committed what it changed and pushed the branch. The script then printed the run’s
diff. Open your repository: the branch has the agent’s commit, ready for a pull request. Maitento never opens the pull
request or merges for you.
Done when: your repository has a maitento/add-shout-flag branch with the agent’s commit.
4. Check the work: a test phase and a hook
Section titled “4. Check the work: a test phase and a hook”The developer added a test, but nothing in the pipeline checked that the tests pass. The tested-change pipeline adds a second agent, a tester,
in a phase of its own that runs after the developer’s:
{ "name": "tested-change", "durationAutoTermination": "00:45:00", "maxFixIterations": 2, "promptInputs": [ { "name": "request", "description": "The change to make, in plain words", "isRequired": true } ], "agents": [ { "name": "developer", "defaultEngine": "DeepSeek", "role": "Fullstack" }, { "name": "tester", "defaultEngine": "DeepSeek", "role": "Reviewer" } ], "phases": [ { "name": "implement", "steps": [ { "name": "implement", "agentName": "developer", "prompt": "@prompt(implement)", "isCommitable": true } ] }, { "name": "test", "dependsOn": ["implement"], "postHook": "TestGate", "steps": [ { "name": "run-tests", "agentName": "tester", "prompt": "@prompt(run-tests)", "isNewSession": true, "outputVariableName": "testReport" } ] } ]}The tester runs the tests in a fresh session (isNewSession), changes nothing, and ends its reply with the exit code.
outputVariableName keeps that reply in a pipeline variable, testReport:
Run the project's tests and report the result. Don't change any file.
From the repository root, run exactly: python3 -m pytest -q
Say which tests failed, with their error output. End your reply with one line: EXIT CODE: followed by the command's exit code.The test phase has a postHook, TestGate. A hook is a small function in
Cogniscript, the apps’ language, in code-pipeline-hooks/<pipeline>/. A phase’s post hook runs when
the phase has succeeded, and what it returns decides what the pipeline does next:
// Hooks for the tested-change pipeline. Every function here takes (processId, methodName) and returns text.
// The test phase's post hook: it reads the tester's report and decides what the pipeline does next.string TestGate(string processId, string methodName) { string report = PipelineGetVariable(processId, "testReport");
// The tests pass: nothing more to do. if (StringContains(report, "EXIT CODE: 0", false)) { PipelineSetVariable(processId, "testsPassed", "true"); return("{\"decision\": \"done\"}"); }
// They fail: loop back. The developer fixes the code in its own session, with the report in its prompt, then // the tests run again, with this same hook after them. maxFixIterations caps how many times. PipelineSetVariable(processId, "testsPassed", "false"); PipelineLog(processId, "TestGate: the tests failed, looping back to the developer"); string fix = "{\"name\": \"fix\", \"targetRole\": \"Fullstack\", \"prompt\": \"@promptJson(tested-change/fix)\", \"isCommitable\": true}"; string retest = "{\"name\": \"run-tests\", \"targetRole\": \"Reviewer\", \"prompt\": \"@promptJson(tested-change/run-tests)\", \"isNewSession\": true, \"outputVariableName\": \"testReport\"}"; string fixPhase = "{\"name\": \"fix\", \"steps\": [" + fix + "]}"; string retestPhase = "{\"name\": \"retest\", \"dependsOn\": [\"fix\"], \"postHook\": \"TestGate\", \"steps\": [" + retest + "]}"; return("{\"decision\": \"loop\", \"loop\": {\"blockingIssueCount\": 1, \"fixPhases\": [" + fixPhase + ", " + retestPhase + "]}}");}| A post hook returns | The pipeline… |
|---|---|
{"decision": "done"} | carries on with the phases it has, and finishes when they’re done |
{"decision": "loop", "loop": {"fixPhases": [...]}} | adds the fixPhases, named fix-1, retest-1 and so on, after this phase; at most maxFixIterations times (3 unless the pipeline says, at most 5) |
{"decision": "inject", "injectPhases": [...]} | adds the phases once, after this one |
PipelineGetVariableandPipelineSetVariableread and write the run’s variables.%%testReport%%in a prompt is the variable’s value when the step starts, which is how the developer sees the failure.@promptJson(tested-change/fix)puts a prompt file into the hook’s text when you deploy, ready to sit inside a JSON string.- Steps a hook adds name a role (
targetRole), not an agent:Fullstackis the developer,Reviewerthe tester. A step withoutisNewSessioncarries on the agent’s own session, so the developer still knows what it did.
The developer’s fix prompt is the report and one rule:
The tests failed after your change. This is the test report:
%%testReport%%
Fix the code so the tests pass. Change a test only if the change you were asked to make means the test is now wrong.Done when: you can say what TestGate returns when the tests pass, and when they fail.
5. Run the tested pipeline
Section titled “5. Run the tested pipeline”tested.ts asks for a different change, on a branch of its own, then prints every phase that ran, what each post
hook decided, and the variables the hook set:
import { Maitento } from "@maitento/sdk";
// The repository to work on, and credentials that can push to it, e.g. "x-access-token:<github token>".const url = process.env.REPO_URL;const credentials = process.env.REPO_CREDENTIALS;if (!url || !credentials) throw new Error("Set REPO_URL and REPO_CREDENTIALS first.");
// The change to make, and the branch to push it to.const request = process.argv[2] ?? "Add a --times option that prints the greeting that many times, with a test for it.";const branch = process.argv[3] ?? "maitento/add-times-option";
const mt = new Maitento();await mt.deploy("../maitento");
const run = await mt.pipelines.run("tested-change", { inputs: { request }, repos: [{ type: "http", name: "app", url, credentials, branchSource: "main", branchWork: branch }],});
// Every phase that ran, including the ones the hook added, and what its post hook decided.for (const phase of run.phases) { const steps = phase.steps.map((step) => `${step.agentName} ${step.stepName}${step.didCommit ? ", committed" : ""}`); const decision = phase.hookPostOutput ? ` -> ${JSON.parse(phase.hookPostOutput).decision}` : ""; console.log(`${phase.phaseName}: ${phase.status} (${steps.join("; ")})${decision}`);}const variable = (name: string) => run.variables?.find((v) => v.name === name)?.value;console.log(`\nTests passed: ${variable("testsPassed")}`);console.log(`The last test report ends: ${(variable("testReport") ?? "").trim().split("\n").at(-1)}`);console.log(`\nPipeline ${run.status}. The work is on the ${branch} branch.`);import jsonimport osimport sys
from maitento import Maitento
# The repository to work on, and credentials that can push to it, e.g. "x-access-token:<github token>".url = os.environ.get("REPO_URL")credentials = os.environ.get("REPO_CREDENTIALS")if not url or not credentials: sys.exit("Set REPO_URL and REPO_CREDENTIALS first.")
# The change to make, and the branch to push it to.default_request = "Add a --times option that prints the greeting that many times, with a test for it."request = sys.argv[1] if len(sys.argv) > 1 else default_requestbranch = sys.argv[2] if len(sys.argv) > 2 else "maitento/add-times-option"
mt = Maitento()mt.deploy("../maitento")
run = mt.pipelines.run( "tested-change", inputs={"request": request}, repos=[{ "type": "http", "name": "app", "url": url, "credentials": credentials, "branch_source": "main", "branch_work": branch, }],)
# Every phase that ran, including the ones the hook added, and what its post hook decided.for phase in run.phases: steps = [f"{s.agent_name} {s.step_name}{', committed' if s.did_commit else ''}" for s in phase.steps] decision = f" -> {json.loads(phase.hook_post_output)['decision']}" if phase.hook_post_output else "" print(f"{phase.phase_name}: {phase.status} ({'; '.join(steps)}){decision}")
variables = {v.name: v.value for v in run.variables or []}print(f"\nTests passed: {variables.get('testsPassed')}")report = (variables.get("testReport") or "").strip().splitlines()print(f"The last test report ends: {report[-1] if report else ''}")print(f"\nPipeline {run.status}. The work is on the {branch} branch.")#:package Maitento.Sdk@26.10.0
using System.Text.Json;using Maitento.Entities.CodePipelines.Process;using Maitento.Entities.CodePipelines.WebApi;using Maitento.Entities.Database;using Maitento.Sdk;
// The repository to work on, and credentials that can push to it, e.g. "x-access-token:<github token>".var url = Environment.GetEnvironmentVariable("REPO_URL");var credentials = Environment.GetEnvironmentVariable("REPO_CREDENTIALS");if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(credentials)) throw new InvalidOperationException("Set REPO_URL and REPO_CREDENTIALS first.");
// The change to make, and the branch to push it to.var request = args.Length > 0 ? args[0] : "Add a --times option that prints the greeting that many times, with a test for it.";var branch = args.Length > 1 ? args[1] : "maitento/add-times-option";
await using var mt = MaitentoClient.CreateFromEnvironment();await mt.DeployAsync("../maitento");
var run = await mt.CodePipelines.RunAsync("tested-change", new CodePipelineProcessStartRequest{ Inputs = [new NameValuePair("request", request)], Repos = [ new CodePipelineGitHttpRepo { Name = "app", Url = url, Credentials = credentials, BranchSource = "main", BranchWork = branch, }, ],});
// Every phase that ran, including the ones the hook added, and what its post hook decided.foreach (var phase in run.Phases){ var steps = phase.Steps.Select(step => $"{step.AgentName} {step.StepName}{(step.DidCommit ? ", committed" : "")}"); var decision = phase.HookPostOutput is { } hook ? $" -> {JsonDocument.Parse(hook).RootElement.GetProperty("decision").GetString()}" : ""; Console.WriteLine($"{phase.PhaseName}: {phase.Status} ({string.Join("; ", steps)}){decision}");}
string? Variable(string name) => run.Variables?.FirstOrDefault(v => v.Name == name)?.Value;Console.WriteLine($"\nTests passed: {Variable("testsPassed")}");Console.WriteLine($"The last test report ends: {(Variable("testReport") ?? "").Trim().Split('\n').Last()}");Console.WriteLine($"\nPipeline {run.Status}. The work is on the {branch} branch.");implement: Finished (developer implement, committed)test: Finished (tester run-tests) -> done
Tests passed: trueThe last test report ends: EXIT CODE: 0
Pipeline Finished. The work is on the maitento/add-times-option branch.The developer made the change and committed it; the tester ran the tests in a session of its own; the hook read
the report, found EXIT CODE: 0, set testsPassed and said done. This run took two minutes.
Done when: the run ends Finished with Tests passed: true, and the maitento/add-times-option branch has the
developer’s commit.
6. Break it on purpose: a change that breaks a test
Section titled “6. Break it on purpose: a change that breaks a test”Ask for a change that the existing test disagrees with, and tell the developer to leave the tests alone:
node tested.ts "Change the greeting to 'Hi, <name>!'. Don't change the tests." maitento/say-hiimplement: Finished (developer implement, committed)test: Finished (tester run-tests) -> loopfix-1: Finished (developer fix, committed)retest-1: Finished (tester run-tests) -> done
Tests passed: trueThe last test report ends: EXIT CODE: 0
Pipeline Finished. The work is on the maitento/say-hi branch.What happened, step by step:
-
implement: the developer changed the greeting and, as told, left the tests alone.
-
test: the tester’s run failed. Its report, the
testReportvariable, ended like this:E AssertionError: assert 'Hi, Ana!' == 'Hello, Ana!'…1 failed in 0.01sEXIT CODE: 1 -
The hook didn’t find
EXIT CODE: 0, so it returnedloop, and the pipeline addedfix-1andretest-1. -
fix-1: the developer, still in its own session, read the report. The change it had been asked for made the old test wrong, so it updated the test, and the fix was committed and pushed.
-
retest-1: a fresh tester ran the tests again; they passed, and the same hook said
done.
If the tests had still been failing after maxFixIterations rounds (2 here), the pipeline would have stopped
looping and finished anyway, with testsPassed set to false. A hook decides what runs next, but it can’t fail a
run, so check testsPassed before you open a pull request.
Done when: you see fix-1 and retest-1 in the list, and the maitento/say-hi branch has two commits.
All the files
Section titled “All the files”Every file in this tutorial’s project, including the sample repository:
On GitHub: examples/22-code-pipelines · TypeScript · Python · C#
All the files in this project (11)
// Hooks for the tested-change pipeline. Every function here takes (processId, methodName) and returns text.
// The test phase's post hook: it reads the tester's report and decides what the pipeline does next.string TestGate(string processId, string methodName) { string report = PipelineGetVariable(processId, "testReport");
// The tests pass: nothing more to do. if (StringContains(report, "EXIT CODE: 0", false)) { PipelineSetVariable(processId, "testsPassed", "true"); return("{\"decision\": \"done\"}"); }
// They fail: loop back. The developer fixes the code in its own session, with the report in its prompt, then // the tests run again, with this same hook after them. maxFixIterations caps how many times. PipelineSetVariable(processId, "testsPassed", "false"); PipelineLog(processId, "TestGate: the tests failed, looping back to the developer"); string fix = "{\"name\": \"fix\", \"targetRole\": \"Fullstack\", \"prompt\": \"@promptJson(tested-change/fix)\", \"isCommitable\": true}"; string retest = "{\"name\": \"run-tests\", \"targetRole\": \"Reviewer\", \"prompt\": \"@promptJson(tested-change/run-tests)\", \"isNewSession\": true, \"outputVariableName\": \"testReport\"}"; string fixPhase = "{\"name\": \"fix\", \"steps\": [" + fix + "]}"; string retestPhase = "{\"name\": \"retest\", \"dependsOn\": [\"fix\"], \"postHook\": \"TestGate\", \"steps\": [" + retest + "]}"; return("{\"decision\": \"loop\", \"loop\": {\"blockingIssueCount\": 1, \"fixPhases\": [" + fixPhase + ", " + retestPhase + "]}}");}{ "name": "add-feature", "durationAutoTermination": "00:30:00", "promptInputs": [ { "name": "request", "description": "The change to make, in plain words", "isRequired": true } ], "agents": [ { "name": "developer", "defaultEngine": "DeepSeek", "role": "Fullstack" } ], "phases": [ { "name": "development", "steps": [ { "name": "implement", "agentName": "developer", "prompt": "@prompt(implement)", "isCommitable": true } ] } ]}Make this change to the repository:
%%input:request%%
Keep the change small and focused. Add or update a test if the project has tests.{ "name": "tested-change", "durationAutoTermination": "00:45:00", "maxFixIterations": 2, "promptInputs": [ { "name": "request", "description": "The change to make, in plain words", "isRequired": true } ], "agents": [ { "name": "developer", "defaultEngine": "DeepSeek", "role": "Fullstack" }, { "name": "tester", "defaultEngine": "DeepSeek", "role": "Reviewer" } ], "phases": [ { "name": "implement", "steps": [ { "name": "implement", "agentName": "developer", "prompt": "@prompt(implement)", "isCommitable": true } ] }, { "name": "test", "dependsOn": ["implement"], "postHook": "TestGate", "steps": [ { "name": "run-tests", "agentName": "tester", "prompt": "@prompt(run-tests)", "isNewSession": true, "outputVariableName": "testReport" } ] } ]}The tests failed after your change. This is the test report:
%%testReport%%
Fix the code so the tests pass. Change a test only if the change you were asked to make means the test is now wrong.Make this change to the repository:
%%input:request%%
Keep the change small and focused.Run the project's tests and report the result. Don't change any file.
From the repository root, run exactly: python3 -m pytest -q
Say which tests failed, with their error output. End your reply with one line: EXIT CODE: followed by the command's exit code.__pycache__/*.pyc.pytest_cache/# greeting-cli
A tiny command-line greeter. Push this folder to a repository of your own to try the code pipeline tutorial.
```bashpython greet.py Ana # Hello, Ana!python -m pytest # runs the tests```"""A tiny command-line greeter, used as the starting point for the code pipeline tutorial."""import argparse
def greeting(name: str) -> str: return f"Hello, {name}!"
def main() -> None: parser = argparse.ArgumentParser(description="Print a greeting.") parser.add_argument("name", help="who to greet") args = parser.parse_args() print(greeting(args.name))
if __name__ == "__main__": main()from greet import greeting
def test_greeting(): assert greeting("Ana") == "Hello, Ana!"If it didn’t work
Section titled “If it didn’t work”- The run ends
Errorwhile cloning, or pushing fails: checkREPO_URL(thehttpsaddress, ending.git) andREPO_CREDENTIALS(<user>:<token>), and that the token can write to the repository. - The step fails at once with an error about model keys: the
DeepSeekengine uses your tenant’s DeepSeek key. Add one (Model keys), or use another engine you have a key for. - The hook never runs: the hook folder must have the pipeline’s name (
code-pipeline-hooks/tested-change/), andpostHookmust name a function in it exactly. - The loop keeps going round and the tests never pass: look at
testReport. If the tests can’t start at all (a missing tool, a wrong command), no fix to the code will help: change the test step’s prompt. Every fix is committed, so delete the branch before you try again.
Review
Section titled “Review”What you built: a pipeline that changes your repository and pushes a branch, and a second one that tests the change, sends failures back to the developer, and stops when the tests pass or the limit is reached.
The concepts:
- A code pipeline runs coding agents on your repositories, in phases and steps.
- Engines are the coding tools (Claude Code, Codex, Gemini CLI and more); each uses your key for its provider.
- Committable steps commit and push to the work branch as soon as they succeed.
- Variables carry a step’s output to later prompts (
%%name%%) and to hooks. - Hooks run after a phase or a step, and a phase’s post hook can add phases while the pipeline runs:
loopto repeat work up to a limit,injectto add it once.
Challenge: once the tests pass, have the pipeline update the README to show the new option, in a phase the hook
adds. Hint: the inject decision, and a new prompt file. A solution in TypeScript, Python and C# is in
examples/22-code-pipelines/solution:
implement: Finished (developer implement, committed)test: Finished (tester run-tests) -> injectdocs: Finished (developer update-readme, committed)
Pipeline Finished. The work is on the maitento/times-with-docs branch.The solution is a pipeline of its own, tested-change-docs, so it doesn’t replace this tutorial’s.
Where next
Section titled “Where next”You’ve now used every part of Maitento. From here:
- 23. Support desk puts most of them together in one small web app;
- the concepts go deeper on each idea;
- every API call is in the API reference, with curl and SDK examples, and you can try any of them in the API playground.