Build a Code Hosting Platform
Repos, branches, commits, pull requests, and native AI agent access — a complete GitHub alternative built entirely on drops and entities. This is how repos.infiniteocean.io is built.
POST /exec or paste into the live editor.
A version-controlled code hosting platform seems like it needs a specialized database, a custom file system, and years of engineering. But if your storage layer already has append-only immutable writes, entity keys, version history, bulk operations, and full-text search — you have everything you need.
This tutorial walks through building the actual Ocean Repos product, step by step. By the end, you'll have user handles, repositories with file trees, branches, commits, pull requests, and MCP agent integration — all from a handful of commands.
1Architecture
The entire platform maps to InfiniteOcean routes and entities:
| Concept | Route | IO Primitive |
|---|---|---|
| User handles | _handles/{handle} | Entity (key = handle name) |
| Repo config | repos/{handle}/{repo}/_meta | Entity (key = config) |
| Files | repos/{handle}/{repo}/{branch} | Entity (key = file path) |
| Branches | repos/{handle}/{repo}/_branches | Entity (key = branch name) |
| Commits | repos/{handle}/{repo}/_commits | Entity (key = commit ID) |
| Pull requests | repos/{handle}/{repo}/_pulls | Entity (key = PR number) |
| Repo index | _repo_index/{handle}/{repo} | Entity (discovery) |
Every concept in version control maps to an entity. Files are entities where the key is the file path (src/index.js) and the payload is the file content. Branches are routes — the same file path under different branch routes holds different versions. Commits are append-only log entries. This is the entire architecture.
?history=true), append-only immutable writes, and tombstones for deletion. You don't need to build versioning — it's built into the storage layer.
2Register a Handle
Before creating repos, users claim a human-readable handle. This is a simple write to the _handles route:
write _handles @niklas { owner_key: "YOUR_KEY", display_name: "Niklas", created_at: "2026-02-13T12:00:00Z" }
- _handles
- A shared route for all user handles. Readable by anyone, write-protected per owner.
- key
- The handle name itself. Lowercase, alphanumeric + hyphens, 2–39 characters.
- owner_key
- The Ocean key that owns this handle. Used for write authorization.
Ownership is enforced application-side: before writing, check if the handle already exists. If it does and the owner_key doesn't match the requester, reject the write. First writer wins.
-- Look up a handle
read _handles @niklas
-- Returns:
-- { "payload": { "owner_key": "YRV...", "display_name": "Niklas" } }
3Create a Repository
A repository is a set of related entities under a route prefix. Creating one means writing a config entity, a default branch, and an initial commit. Use bulk to do it atomically:
bulk [
write repos/niklas/my-project/_meta @config { name: "my-project", description: "A cool project", default_branch: "main", visibility: "public", owner_key: "YOUR_KEY", language: "javascript" },
write repos/niklas/my-project/_branches @main { created_at: "2026-02-13T12:00:00Z", status: "active" },
write repos/niklas/my-project/main @README.md "# my-project\n\nA cool project.\n",
write repos/niklas/my-project/_commits @init { message: "Initial commit", branch: "main", files: [{ path: "README.md", action: "add" }] },
write _repo_index @niklas/my-project { name: "my-project", description: "A cool project", handle: "niklas", language: "javascript" }
]
One command creates everything: the repo config, a main branch, a README file, an initial commit, and an index entry for discovery. Bulk accepts up to 1,000 writes in a single request.
- _meta/config
- Repo settings: name, description, owner, visibility, language.
- _branches/main
- Branch metadata. The key is the branch name.
- main/README.md
- A file. The route includes the branch, the key is the file path.
- _commits/init
- A commit record. Lists which files changed and why.
- _repo_index
- A flat index for listing all repos on the home page.
4Work with Files
Files are entities where the key is the file path and the payload is the content. Reading and writing files is just reading and writing entities:
-- Write a file
write repos/niklas/my-project/main @src/index.js "export function hello() {\n return \"world\";\n}\n"
-- Read it back
read repos/niklas/my-project/main @src/index.js
-- List all files in a branch
list repos/niklas/my-project/main
The list command returns all entities in a route. Filter the keys to build a file tree, or read individual files with the read command.
/, so paths like src/utils/helpers.js work naturally as keys. No encoding needed.
To see the history of a file, use the ?history flag:
-- Get all versions of a file
read repos/niklas/my-project/main @src/index.js ?history
-- Get the file as it was at a specific time
read repos/niklas/my-project/main @src/index.js at "2026-02-13T10:00:00Z"
Version history and point-in-time reads come free from the storage layer. Every write is an immutable append — nothing is overwritten, nothing is lost.
5Make Commits
A commit is a batch of file writes plus a commit record. Use bulk to make them atomic:
bulk [
write repos/niklas/my-project/main @src/index.js "export function hello() {\n return \"world\";\n}\n\nexport function goodbye() {\n return \"farewell\";\n}\n",
write repos/niklas/my-project/main @src/utils.js "export const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);\n",
write repos/niklas/my-project/_commits @commit-002 { message: "Add goodbye function and utils", branch: "main", author: "niklas", files: [{ path: "src/index.js", action: "modify" }, { path: "src/utils.js", action: "add" }], parent: "init" }
]
The file changes and the commit record are written together. The commit entity records which files changed, the commit message, the author, and a reference to the parent commit. To list commits:
-- List all commits
list repos/niklas/my-project/_commits
delete command. This creates a tombstone — the read command returns nothing, but the history preserves the deletion event.
6Create Branches
In Git, branches are lightweight pointers into a DAG. In Ocean Repos, branches are separate routes. Creating a branch means copying files from the source branch to a new route:
// 1. Export all files from source branch
const res = await fetch(
'https://api.infiniteocean.io/export/repos/niklas/my-project/main?latest=true',
{ headers: { 'X-Ocean-Key': KEY } }
);
const lines = (await res.text()).trim().split('\n');
const entities = lines.map(l => JSON.parse(l)).filter(e => !e.tombstone);
// 2. Build bulk write: copy files to new branch + create branch entity
const drops = entities.map(e => ({
route: 'repos/niklas/my-project/feature-auth',
key: e.key,
payload: typeof e.payload === 'string' ? e.payload : JSON.stringify(e.payload),
}));
drops.push({
route: 'repos/niklas/my-project/_branches',
key: 'feature-auth',
payload: JSON.stringify({
created_at: new Date().toISOString(),
source_branch: 'main',
status: 'active',
}),
});
// 3. Atomic bulk write
await fetch('https://api.infiniteocean.io/drops', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Ocean-Key': KEY },
body: JSON.stringify({ drops }),
});
This approach is simpler than Git's DAG model. Each branch is a full snapshot — no merge base computation, no reachability graphs. The tradeoff is storage (full copies vs pointers), but with append-only storage and S3 backend, the cost is negligible.
- main/src/index.js
- File in the main branch.
- feature-auth/src/index.js
- Same file path, different branch route. Independent versions.
7Pull Requests
A pull request is an entity that tracks a proposed merge between branches:
-- Create a pull request
write repos/niklas/my-project/_pulls @1 { title: "Add authentication module", body: "This PR adds login and session management.", source_branch: "feature-auth", target_branch: "main", author: "niklas", state: "open" }
To compute a diff, export both branches and compare:
// Fetch both branches
const [sourceFiles, targetFiles] = await Promise.all([
exportEntities('repos/niklas/my-project/feature-auth'),
exportEntities('repos/niklas/my-project/main'),
]);
// Build maps
const sourceMap = Object.fromEntries(sourceFiles.map(f => [f.key, f.payload]));
const targetMap = Object.fromEntries(targetFiles.map(f => [f.key, f.payload]));
// Find changes
const added = Object.keys(sourceMap).filter(k => !(k in targetMap));
const modified = Object.keys(sourceMap).filter(k =>
k in targetMap && sourceMap[k] !== targetMap[k]
);
const deleted = Object.keys(targetMap).filter(k => !(k in sourceMap));
Merging is the reverse: copy changed files from the source branch to the target branch, then update the PR state to merged.
8Search Code
InfiniteOcean has built-in hybrid search (BM25 + vector). Search across all files in a branch with a single command:
search repos/niklas/my-project/main "function hello" top 10
Results include the matching entity key (the file path) and a relevance score. The search engine indexes entities automatically — write a file, and it's searchable immediately. No configuration, no external search service.
9Agent Access with MCP
This is where it gets interesting. InfiniteOcean has native MCP (Model Context Protocol) support. Every repo can automatically become an MCP server that AI agents connect to directly. An agent can browse, read, write, and search your code through standard tool calls.
To make a repo available as an MCP server, write a config entity:
write _mcp/servers @repos-niklas-my-project {
name: "niklas/my-project",
version: "1.0.0",
app_key: "YOUR_KEY",
tools: {
list_files: {
description: "List all files in the repository",
inputSchema: { type: "object", properties: { branch: { type: "string", default: "main" } } },
function: "functions/_repos"
},
read_file: {
description: "Read a file contents",
inputSchema: { type: "object", properties: { path: { type: "string" }, branch: { type: "string", default: "main" } }, required: ["path"] },
function: "functions/_repos"
},
write_file: {
description: "Create or update a file",
inputSchema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" }, message: { type: "string" }, branch: { type: "string", default: "main" } }, required: ["path", "content", "message"] },
function: "functions/_repos"
},
search_code: {
description: "Search code in the repository",
inputSchema: { type: "object", properties: { query: { type: "string" }, branch: { type: "string", default: "main" } }, required: ["query"] },
function: "functions/_repos"
}
}
}
Now agents can connect at https://api.infiniteocean.io/mcp/niklas/my-project. The MCP endpoint speaks JSON-RPC 2.0 and supports initialize, tools/list, and tools/call.
# Test the MCP endpoint
curl -X POST "https://api.infiniteocean.io/mcp/niklas/my-project?key=YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
# List available tools
curl -X POST "https://api.infiniteocean.io/mcp/niklas/my-project?key=YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# Call a tool
curl -X POST "https://api.infiniteocean.io/mcp/niklas/my-project?key=YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_files","arguments":{"branch":"main"}}}'
To connect from Claude Desktop or Claude Code, add the server URL to your config:
{
"mcpServers": {
"my-repo": {
"url": "https://api.infiniteocean.io/mcp/niklas/my-project?key=YOUR_KEY"
}
}
}
10The Tool Runner
When an agent calls a tool like read_file, the MCP handler dispatches it to a Python function stored as an entity. The function receives an ocean SDK client and a request object, and uses the same API to read and write data:
def main(ocean, request):
tool = request.get('tool', '')
args = request.get('arguments', {})
namespace = request.get('namespace', '') # e.g. "niklas/my-project"
if tool == 'list_files':
branch = args.get('branch', 'main')
route = f'repos/{namespace}/{branch}'
entities = ocean.export_entities(route)
files = [e.get('key') for e in entities
if isinstance(e, dict) and e.get('key') and not e.get('tombstone')]
files.sort()
return {'files': files, 'count': len(files), 'branch': branch}
if tool == 'read_file':
path = args.get('path', '')
branch = args.get('branch', 'main')
result = ocean.read(f'repos/{namespace}/{branch}', path)
return {
'path': path,
'content': result.get('payload', ''),
'size': len(result.get('payload', '')),
}
if tool == 'write_file':
path = args.get('path', '')
content = args.get('content', '')
message = args.get('message', '')
branch = args.get('branch', 'main')
# payload = content, key = path
ocean.write(f'repos/{namespace}/{branch}', content, path)
return {'ok': True, 'path': path, 'message': message}
return {'error': f'Unknown tool: {tool}'}
This function is stored as an entity at functions/_repos with key main. The MCP config references it via the "function" field in each tool definition. When a tool is called, InfiniteOcean reads the function code, sends it to the runner fleet for execution, and returns the result as an MCP tool response.
- ocean.read(route, key)
- Read an entity. Returns the latest version.
- ocean.write(route, payload, key)
- Write a drop. Payload is the content, key identifies the entity.
- ocean.export_entities(route)
- Export all entities in a route as a list. Used for listing files.
- ocean.search(route, text)
- Full-text search across entities in a route.
11The Web UI
The web frontend is a single-page app stored as a _site entity, served at repos.infiniteocean.io via InfiniteOcean's view server. The entire UI — file tree, code viewer, commit history, PR pages — is one HTML file with client-side routing.
// Client-side router
const path = location.pathname;
if (path === '/' || path === '') {
renderHome(app); // List all repos
} else if (path.match(/^\/[^/]+$/)) {
renderProfile(app, path.slice(1)); // User profile
} else if (path.match(/^\/[^/]+\/[^/]+$/)) {
const [handle, repo] = path.slice(1).split('/');
renderRepo(app, handle, repo); // Repo home
}
// Each render function fetches from the API:
async function renderRepo(app, handle, repo) {
const [config, files, readme] = await Promise.all([
readEntity(`repos/${handle}/${repo}/_meta`, 'config'),
exportEntities(`repos/${handle}/${repo}/main`),
readEntity(`repos/${handle}/${repo}/main`, 'README.md'),
]);
// ... render file tree, README, stats
}
The SPA reads everything from the InfiniteOcean API. No backend server, no database queries — the frontend calls the same endpoints you've been using throughout this tutorial. Deploying an update is a single write to update the _site/repos-app entity.
12What We Built
From a handful of commands, we built a complete code hosting platform:
- User handles — entity keys in
_handles - Repositories — route prefixes with config entities
- Files — entities with file paths as keys
- Version history — free from the storage layer (
?history=true) - Branches — separate routes, bulk-copied from source
- Commits — batch writes + log entities
- Pull requests — entity-based state tracking
- Code search — built-in hybrid search
- AI agent access — native MCP servers
- Web UI — SPA served from a
_siteentity
No Git, no specialized version control database, no custom file system. Just drops, entities, and the commands documented throughout this tutorial. The append-only storage layer provides immutability and version history. The entity system provides key-value semantics. The list command provides file enumeration. Search provides discovery. MCP provides agent access. And the view server provides hosting.