Code repos
Git repositories your code and agents commit to, with plain git access and export to GitHub.
A repo is a git repository NoClick hosts. Your code commits to it through the API, your agents write to it from a sandbox, and any git client clones it or pushes to it with a short-lived URL. An app deploys from one. It's plain git all the way through, so the code can always leave: clone it, or move it to GitHub whole.
Create a repo
Make your code the definition: from_name with create_if_missing=True creates the repo, empty, the first time it's used and finds it every time after.
import noclick
repo = noclick.Repo.from_name("agent-tools", create_if_missing=True)
print(repo.default_branch) # maincreate makes one outright and raises noclick.Conflict if the name is taken (its existing_id names the one that's there). Pass default_branch= for a first branch other than main:
site = noclick.Repo.create("portfolio", default_branch="production", user="user_42")Created inside as_user (or with user=), a repo belongs to that user.
Commit files
commit writes files in one commit. Each path maps to its new content, text or bytes, or to None to delete it:
repo = noclick.Repo.from_name("agent-tools")
commit = repo.commit(
{
"src/index.ts": "export const lookup = (id: string) => fetch(`/orders/${id}`);\n",
"assets/logo.png": open("logo.png", "rb").read(),
"old-notes.txt": None,
},
message="Add the lookup route",
)
print(commit.sha, commit.files_changed)A commit goes on the default branch unless you pass branch=; a branch that doesn't exist yet is made from the default branch's head, which is how you open a branch for a preview deployment. git keeps branch names as paths, so a name can't be both a branch and another branch's folder: with a branch feature, a commit to feature/new-form raises noclick.Conflict (its existing_branch is feature), so name it feature-new-form. author= names who made it, as Name <email>.
Commit a folder
Pass a folder instead and the branch's tree is made to match it, sending only what changed: new and changed files are written, and files the folder no longer has are deleted. So a build can commit its output on every run, and a run that changed nothing makes no commit and returns None:
site = noclick.Repo.from_name("marketing-site", create_if_missing=True)
commit = site.commit("./dist", message="Rebuild the site", exclude=["*.map", "node_modules"])
print(commit.sha if commit else "nothing changed")A file counts as changed when its content differs from the branch's copy (the client compares git's blob ids, RepoFile.sha, so unchanged files are never uploaded). .git is always skipped, and exclude globs skip more; a path they match is also left alone in the repo, never deleted. The limits of one commit apply to what changed.
Read files
repo = noclick.Repo.from_name("booking")
print(repo.read("package.json").decode())
for entry in repo.files("app/"):
print(entry.type, entry.path, entry.size) # dir app/routes None, file app/root.tsx 812read gives a file's bytes, and files lists one directory, directories first; recursive=True lists every file below it instead. Both read the default branch's head unless you pass ref=, a branch, a tag or a commit sha. A path that isn't there raises noclick.NotFound: a file, or a folder (git keeps no empty folders, so a folder with nothing in it isn't there either), and its field says which was missing, path, prefix or the ref.
Branches and history
repo = noclick.Repo.from_name("booking")
for branch in repo.branches():
print(branch.name, branch.head, branch.updated_at)
for commit in repo.commits(branch="main", limit=5):
print(commit.sha[:7], commit.author, commit.message)last_commit on the repo is the newest commit on its default branch, and used_by lists the apps that deploy it. commits_page reads the history a page at a time: pass each page's next_cursor back as cursor, until it's None.
diff reads what one commit changed against its first parent, from a full or short sha:
repo = noclick.Repo.from_name("booking")
[latest] = repo.commits(limit=1)
changes = repo.diff(latest.sha)
print(f"+{changes.additions} -{changes.deletions}", changes.parents)
for file in changes.files:
print(file.status, file.path) # added, modified, deleted, renamed, copied or type_changed
print(file.patch or "(not shown)")Each file's patch is its unified diff in git's format. It's None, with no line counts, for a file that isn't text or is too large to show; its size still says how big it is.
A commit through the API only adds history: the new commit's parent is the branch's head. With git, a push can rewrite a branch (a force-push) or delete one, so NoClick holds agents to two rules:
- An agent never rewrites or deletes the default branch. Its pushes may only move the default branch forward; a force-push or a deletion of it is refused in git's own report (
! [remote rejected] main -> main (NoClick: the default branch (main) can't be force-pushed or deleted by an agent; push a new branch instead)). So is an update NoClick can't show only adds commits: it never guesses. - Any other branch an agent rewrites or deletes is kept first. Before the push goes on, NoClick keeps the branch's old head as a hidden backup, outside the branch list and clones, for 30 days, like the trash. If it can't keep it, the push is refused and nothing moves.
backups lists them, newest first, and restore_backup makes the branch again at the commit it was kept at. When the repo has a branch by that name again, it comes back as <branch>-restored-<time> instead, so nothing that's there moves:
repo = noclick.Repo.from_name("booking")
for backup in repo.backups(branch="agent/new-form"):
print(backup.reason, backup.sha[:7], backup.created_at, backup.expires_at) # rewritten or deleted
[latest] = repo.backups(branch="agent/new-form", limit=1)
print(repo.restore_backup(latest).name)An agent here is anything that pushes with a git URL it got through NoClick's MCP server (and the noclick deploy command its repos_push hands out), the coordinator, or a platform agent of yours: its writing URL goes through NoClick, which judges each push before the repo sees it. A URL you ask for with a secret key, a user's session or signed in to the console goes straight to the repo as before, and its pushes are yours: nothing judges them, and nothing is kept. A secret key you hand a coding agent yourself (a setup code's .env line, say) is a key like any other, so its pushes aren't covered either; give that agent the MCP server instead.
Clone and push with git
clone_url gives an HTTPS URL with a token inside, for any git client. It reads by default; write=True lets it push too. The token works for this repo only and stops after ttl_s seconds (an hour by default, a day at most), so treat the URL as a secret until then. An agent's writing URL goes through NoClick, which holds its pushes to the rules in Branches and history.
import subprocess
repo = noclick.Repo.from_name("agent-tools")
subprocess.run(["git", "clone", repo.clone_url(), "agent-tools"], check=True)Code that runs its own sandbox gives it a write URL for as long as the job takes, and it commits and pushes with git like anyone else:
repo = noclick.Repo.from_name("agent-tools")
url = repo.clone_url(write=True, ttl_s=900)
with noclick.Sandbox.create(network=True, timeout_s=900) as sb:
sb.exec(["git", "clone", url, "/workspace/agent-tools"])
sb.exec("cd /workspace/agent-tools && npm test")Work in a repo from an agent
A hosted agent gets its repositories with repos=: each is cloned into /workspace/ at a conversation's first turn, on the branch you name (the default branch otherwise), and its git reaches that repository only, through NoClick, with a key made for its sandbox that the model can't turn into the real credential. connection.checkout("owner/name") is a repository a github connection reaches (see Give it a repository), and repo.checkout() is one of your own repos:
import noclick
github = noclick.Connection.from_name("github", integration="github", create_if_missing=True)
site = noclick.Repo.from_name("booking")
writer = noclick.Agent.from_name(
"docs-writer",
create_if_missing=True,
instructions="You fix the docs issues you're given in /workspace/docs, push a branch and open a pull request.",
harness="claude_code",
model_connection="claude",
tools=[github.only("create_pull_request")],
repos=[
github.checkout("acme/docs", push="noclick/*"),
site.checkout(branch="main", path="booking"),
],
)push lists the branches the agent may push to: exact names ("main"), or a prefix ending in /* ("noclick/*" reaches noclick/fix and noclick/a/b), or a list of them. That's the one glob a git endpoint can enforce, so any other ("fix-*", "**") is refused when you make the checkout. NoClick's git endpoint refuses a push to any other branch, and a checkout without push is read-only. Whatever push allows, the branch rules hold too: the agent can't force-push or delete the repository's default branch (on GitHub, the one GitHub names), and before its push rewrites or deletes any other branch NoClick keeps the old head. On GitHub that backup is a ref in the repository itself, refs/noclick/backups/<branch>/<time>, made through GitHub's API with the connection's token before the push goes on, and deleted 30 days later; git ls-remote origin 'refs/noclick/backups/*' lists them, and git push origin <sha>:refs/heads/<branch>, as yourself, restores one. A policy can't do this job: a coding harness runs git push in its own shell, which is no tool call for a policy to judge, so the limit sits in NoClick's git endpoint itself. path is the folder under /workspace (the repository's name by default). A connection from another scope, your account's github in a user's agent say, will be shared under the same rules as in tools; today a checkout takes a connection of the agent's own scope.
Hear about pushes
Every move of a branch, a git push or a commit through the API, sends a repo.pushed event naming the repo, the branch, and its commits before and after (before is None for a new branch, after for a deleted one). An app can deploy on each push by itself (deploy_on_push); or deploy from the event on your own terms, a preview for every branch say:
import noclick
def on_event(event: noclick.Event) -> None:
pushed = event.data
if event.type == "repo.pushed" and pushed["repo"] == "booking" and pushed["after"]:
noclick.Project.from_name("booking").deploy(branch=pushed["branch"])Move it to GitHub
export_to_github copies every branch and tag, with its history, into a new GitHub repository, through a GitHub connection you already have: one of the repo's user, or the account's. owner is the connected GitHub account's own login, or an organization it may create repositories in. A connection with no GitHub account connected yet raises noclick.NeedsConnection, whose connect_url connects it.
repo = noclick.Repo.from_name("agent-tools")
try:
url = repo.export_to_github(connection="github", owner="acme", name="agent-tools").wait()
print(url) # https://github.com/acme/agent-tools
except noclick.NeedsConnection as needed:
print("Connect GitHub first:", needed.connect_url)The GitHub repository is private unless you pass private=False. The copy fails with noclick.Conflict when GitHub already has one by that name, and with noclick.PermissionDenied when the connected account can't create repositories in owner. GitHub takes up to 2 GB in one push. The NoClick repo stays as it was: moving the code doesn't move the apps that deploy it.
Export and delete
export is an operation that gives an export bundle: a zip holding repos/<name>.bundle, every branch and tag with its history as a git bundle, which git clone reads as it is (its default branch checked out), and git push --mirror from that clone moves anywhere.
repo = noclick.Repo.from_name("agent-tools")
bundle = repo.export().wait()
print(bundle.url) # the link expiresdelete removes the repo and its history. A repo an app deploys from raises noclick.Conflict until that app is deleted.
noclick.Repo.from_name("scratch").delete()Limits and billing
| Limit | Value |
|---|---|
| One repo | 5 GB |
| One file | 100 MB |
One commit | 1,000 files, 50 MB |
| A clone URL's lifetime | 60 seconds to 1 day |
A repo is billed for what it stores, its files' content, by the hour at what its git store charges: $0.005 per GB-hour while the repo has changed in the last 7 days, and $0.0002 per GB-hour once it hasn't. Clones and pushes cost nothing extra, within fair use. Spend counts toward the cap of the user the repo belongs to.
Errors
| Error | When |
|---|---|
NotFound | no file or folder at that path, no such ref, no such backup, or no repo by that name or id for the user the call acts for |
Conflict | the name is taken; a new branch named as an existing branch's folder, or holding one (existing_branch); delete on a repo an app deploys from; a GitHub repository that already exists; an export of the repo already running |
NeedsConnection | export_to_github through a connection with no GitHub account connected yet (connect_url) |
PermissionDenied | export_to_github to an owner the connected GitHub account can't create repositories in |
InvalidRequest | a commit or file over the limits, a clone URL lifetime out of range, or a push pattern that isn't a branch or a prefix/* |
QuotaExceeded | a push or commit that would take the repo past 5 GB |
Every method is in the Repo reference: commit, files, clone_url, backups, export_to_github and the rest.