Bot users
Accounts your scripts use to work in Anchor, also known as service accounts.
Building with a bot?
Point your AI agent to this page when it writes code that runs as a bot user.
What is a bot user?
A bot user is an account for code rather than a person: a nightly job, a CI pipeline, a backend service. It belongs to one org and takes a seat there. You share files and folders with it just as you would with a teammate, and it sees only what is shared with it or with the whole org. Its script authenticates with an API key, trades it for a short-lived token, and calls the same MCP tools your AI agent uses. A bot can't sign in to Anchor, and it never owns anything at the top of an org: whatever it creates goes in a folder a person owns.
Getting started
- As an org owner or manager, open Manage org → Membership → Bots, add a bot and create a key. Copy the key right away: it is shown only once.
- Share the folders the bot should work in with it, from each folder's Share dialog.
- Your script trades the key for a token at
POST https://<host>/api/external/orgs/bots/key-exchange, then calls tools athttps://mcp.<host>/mcpwithAuthorization: Bearer <token>. A token lasts 60 to 3,600 seconds, as asked for; get a new one when it runs out.
<host> is anchor.cc, or your install's address if you self-host.
The samples below read it from the ANCHOR_HOST environment variable.
Best practice: connect your AI agent to Anchor's MCP while you write the script, so it can list every tool and its exact inputs itself.
Calling each tool
This works in any language that can make HTTPS calls. Below are Python, TypeScript, C# and Java. First the setup every sample uses, then one small function per tool.
A bot has every tool below. It doesn't have the tools for building apps, documents and
presentations, or for the org's connected services: build_app, unpack_file,
commit_document, commit_presentation and list_mcp_servers.
Setup
import json, os, urllib.request
# anchor.cc, or your install's address if you self-host.
ANCHOR_HOST = os.environ.get("ANCHOR_HOST", "anchor.cc")
KEY_EXCHANGE_URL = f"https://{ANCHOR_HOST}/api/external/orgs/bots/key-exchange"
MCP_URL = f"https://mcp.{ANCHOR_HOST}/mcp"
def post(url, body, token=None):
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(url, json.dumps(body).encode(), headers, method="POST")
with urllib.request.urlopen(request) as response:
return response.read().decode()
# The key comes from the environment, put there by your secret manager.
token = json.loads(post(KEY_EXCHANGE_URL, {
"key": os.environ["ANCHOR_BOT_KEY"],
"ttlSeconds": 900,
}))["token"]
def call(name, arguments):
body = post(MCP_URL, {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": name,
"arguments": arguments,
},
}, token)
# The reply may come as a server-sent event; its JSON is on the "data:" line.
data = next((line[5:] for line in body.splitlines() if line.startswith("data:")), body)
reply = json.loads(data)
if "error" in reply:
raise RuntimeError(reply["error"]["message"])
if reply["result"].get("isError"):
raise RuntimeError(reply["result"]["content"][0]["text"])
return reply["result"]["structuredContent"]
// Node 18 or later, run as an ES module (it awaits at the top level).
// anchor.cc, or your install's address if you self-host.
const ANCHOR_HOST = process.env.ANCHOR_HOST ?? 'anchor.cc';
const KEY_EXCHANGE_URL = `https://${ANCHOR_HOST}/api/external/orgs/bots/key-exchange`;
const MCP_URL = `https://mcp.${ANCHOR_HOST}/mcp`;
async function post(url: string, body: unknown, token?: string): Promise<string> {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
return response.text();
}
// The key comes from the environment, put there by your secret manager.
const { token } = JSON.parse(
await post(KEY_EXCHANGE_URL, {
key: process.env.ANCHOR_BOT_KEY,
ttlSeconds: 900,
})
);
async function call(name: string, args: object): Promise<any> {
const body = await post(
MCP_URL,
{
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name,
arguments: args,
},
},
token
);
// The reply may come as a server-sent event; its JSON is on the "data:" line.
const data = body.split('\n').find((line) => line.startsWith('data:'))?.slice(5) ?? body;
const reply = JSON.parse(data);
if (reply.error) throw new Error(reply.error.message);
if (reply.result.isError) throw new Error(reply.result.content[0].text);
return reply.result.structuredContent;
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// Call Anchor.Connect() once, then the functions below.
static class Anchor
{
// anchor.cc, or your install's address if you self-host.
static readonly string Host = Environment.GetEnvironmentVariable("ANCHOR_HOST") ?? "anchor.cc";
static readonly string KeyExchangeUrl = $"https://{Host}/api/external/orgs/bots/key-exchange";
static readonly string McpUrl = $"https://mcp.{Host}/mcp";
static readonly HttpClient Http = new();
static string? token;
static async Task<string> Post(string url, object body, string? bearer = null)
{
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
};
request.Headers.TryAddWithoutValidation("Accept", "application/json, text/event-stream");
if (bearer != null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
using var response = await Http.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
// The key comes from the environment, put there by your secret manager.
public static async Task Connect()
{
var body = await Post(KeyExchangeUrl, new
{
key = Environment.GetEnvironmentVariable("ANCHOR_BOT_KEY"),
ttlSeconds = 900,
});
token = JsonNode.Parse(body)!["token"]!.GetValue<string>();
}
public static async Task<JsonNode?> Call(string name, object arguments)
{
var body = await Post(McpUrl, new
{
jsonrpc = "2.0",
id = 1,
method = "tools/call",
@params = new
{
name,
arguments,
},
}, token);
// The reply may come as a server-sent event; its JSON is on the "data:" line.
var data = body.Split('\n').FirstOrDefault(line => line.StartsWith("data:"))?[5..] ?? body;
var reply = JsonNode.Parse(data)!;
if (reply["error"] != null) throw new Exception(reply["error"]!["message"]!.ToString());
var result = reply["result"]!;
if (result["isError"]?.GetValue<bool>() == true)
throw new Exception(result["content"]![0]!["text"]!.ToString());
return result["structuredContent"];
}
}
// Uses Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
// Call Anchor.connect() once, then the functions below.
class Anchor {
// anchor.cc, or your install's address if you self-host.
static final String HOST = Objects.requireNonNullElse(System.getenv("ANCHOR_HOST"), "anchor.cc");
static final String KEY_EXCHANGE_URL = "https://" + HOST + "/api/external/orgs/bots/key-exchange";
static final String MCP_URL = "https://mcp." + HOST + "/mcp";
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static String token;
static String post(String url, Object body, String bearer) throws Exception {
var request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (bearer != null) request.header("Authorization", "Bearer " + bearer);
var response = HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) throw new RuntimeException(url + ": HTTP " + response.statusCode());
return response.body();
}
// The key comes from the environment, put there by your secret manager.
static void connect() throws Exception {
var body = post(KEY_EXCHANGE_URL, Map.of(
"key", System.getenv("ANCHOR_BOT_KEY"),
"ttlSeconds", 900), null);
token = JSON.readTree(body).get("token").asText();
}
static JsonNode call(String name, Map<String, Object> arguments) throws Exception {
var body = post(MCP_URL, Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "tools/call",
"params", Map.of(
"name", name,
"arguments", arguments)), token);
// The reply may come as a server-sent event; its JSON is on the "data:" line.
var data = body.lines().filter(line -> line.startsWith("data:"))
.findFirst().map(line -> line.substring(5)).orElse(body);
var reply = JSON.readTree(data);
if (reply.has("error")) throw new RuntimeException(reply.get("error").get("message").asText());
var result = reply.get("result");
if (result.path("isError").asBoolean())
throw new RuntimeException(result.get("content").get(0).get("text").asText());
return result.get("structuredContent");
}
}
ls
List what is in a folder the bot can see.
def ls(folder_id):
return call("ls", {
"target": {
"by": "folder",
"folder_id": folder_id,
},
})
const ls = (folderId: string) =>
call('ls', {
target: {
by: 'folder',
folder_id: folderId,
},
});
static Task<JsonNode?> Ls(string folderId) =>
Anchor.Call("ls", new
{
target = new
{
by = "folder",
folder_id = folderId,
},
});
static JsonNode ls(String folderId) throws Exception {
return Anchor.call("ls", Map.of(
"target", Map.of(
"by", "folder",
"folder_id", folderId)));
}
mkdir
Create a folder inside another folder. A bot can't create one at the top of an org.
def mkdir(parent_folder_id, name):
return call("mkdir", {
"folder_id": parent_folder_id,
"name": name,
})
const mkdir = (parentFolderId: string, name: string) =>
call('mkdir', {
folder_id: parentFolderId,
name,
});
static Task<JsonNode?> Mkdir(string parentFolderId, string name) =>
Anchor.Call("mkdir", new
{
folder_id = parentFolderId,
name,
});
static JsonNode mkdir(String parentFolderId, String name) throws Exception {
return Anchor.call("mkdir", Map.of(
"folder_id", parentFolderId,
"name", name));
}
write_file
Create a text file (Markdown, HTML, JSON, CSV, …). Takes up to 100 files in one call.
def write_file(folder_id, name, text):
return call("write_file", {
"writes": [
{
"folder_id": folder_id,
"name": name,
"text_content": {"newString": text},
},
],
})
const writeFile = (folderId: string, name: string, text: string) =>
call('write_file', {
writes: [
{
folder_id: folderId,
name,
text_content: { newString: text },
},
],
});
static Task<JsonNode?> WriteFile(string folderId, string name, string text) =>
Anchor.Call("write_file", new
{
writes = new[]
{
new
{
folder_id = folderId,
name,
text_content = new { newString = text },
},
},
});
static JsonNode writeFile(String folderId, String name, String text) throws Exception {
return Anchor.call("write_file", Map.of(
"writes", List.of(Map.of(
"folder_id", folderId,
"name", name,
"text_content", Map.of("newString", text)))));
}
read_file
Read a text file's content.
def read_file(file_id):
return call("read_file", {
"file_id": file_id,
"start_line": None,
"max_lines": None,
})["content"]
const readFile = async (fileId: string): Promise<string> =>
(
await call('read_file', {
file_id: fileId,
start_line: null,
max_lines: null,
})
).content;
static async Task<string> ReadFile(string fileId) =>
(await Anchor.Call("read_file", new
{
file_id = fileId,
start_line = (int?)null,
max_lines = (int?)null,
}))!["content"]!.GetValue<string>();
static String readFile(String fileId) throws Exception {
var args = new HashMap<String, Object>(); // Map.of() can't hold the nulls
args.put("file_id", fileId);
args.put("start_line", null);
args.put("max_lines", null);
return Anchor.call("read_file", args).get("content").asText();
}
upload_file_presigned_url
Upload any file, binary too: get a URL, PUT the bytes to it, then create the file with write_file.
def upload_file(folder_id, name, data: bytes):
url = call("upload_file_presigned_url", {
"folder_id": folder_id,
})["presigned_url"]
urllib.request.urlopen(urllib.request.Request(url, data, method="PUT")).close()
return call("write_file", {
"writes": [
{
"folder_id": folder_id,
"name": name,
"presigned_url": url,
},
],
})
async function uploadFile(folderId: string, name: string, data: Uint8Array) {
const { presigned_url: url } = await call('upload_file_presigned_url', {
folder_id: folderId,
});
const put = await fetch(url, { method: 'PUT', body: data });
if (!put.ok) throw new Error(`upload: HTTP ${put.status}`);
return call('write_file', {
writes: [
{
folder_id: folderId,
name,
presigned_url: url,
},
],
});
}
static async Task<JsonNode?> UploadFile(string folderId, string name, byte[] data)
{
var url = (await Anchor.Call("upload_file_presigned_url", new
{
folder_id = folderId,
}))!["presigned_url"]!.GetValue<string>();
using var http = new HttpClient();
(await http.PutAsync(url, new ByteArrayContent(data))).EnsureSuccessStatusCode();
return await Anchor.Call("write_file", new
{
writes = new[]
{
new
{
folder_id = folderId,
name,
presigned_url = url,
},
},
});
}
static JsonNode uploadFile(String folderId, String name, byte[] data) throws Exception {
var url = Anchor.call("upload_file_presigned_url", Map.of(
"folder_id", folderId)).get("presigned_url").asText();
var put = HttpRequest.newBuilder(URI.create(url))
.PUT(HttpRequest.BodyPublishers.ofByteArray(data)).build();
var response = Anchor.HTTP.send(put, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() != 200) throw new RuntimeException("upload: HTTP " + response.statusCode());
return Anchor.call("write_file", Map.of(
"writes", List.of(Map.of(
"folder_id", folderId,
"name", name,
"presigned_url", url))));
}
download_file_presigned_url
Download a file's bytes: get a URL, then GET it. The URL lasts 15 minutes.
def download_file(file_id) -> bytes:
url = call("download_file_presigned_url", {
"file_id": file_id,
})["presigned_url"]
with urllib.request.urlopen(url) as response:
return response.read()
async function downloadFile(fileId: string): Promise<ArrayBuffer> {
const { presigned_url: url } = await call('download_file_presigned_url', {
file_id: fileId,
});
const response = await fetch(url);
if (!response.ok) throw new Error(`download: HTTP ${response.status}`);
return response.arrayBuffer();
}
static async Task<byte[]> DownloadFile(string fileId)
{
var url = (await Anchor.Call("download_file_presigned_url", new
{
file_id = fileId,
}))!["presigned_url"]!.GetValue<string>();
using var http = new HttpClient();
return await http.GetByteArrayAsync(url);
}
static byte[] downloadFile(String fileId) throws Exception {
var url = Anchor.call("download_file_presigned_url", Map.of(
"file_id", fileId)).get("presigned_url").asText();
var get = HttpRequest.newBuilder(URI.create(url)).GET().build();
return Anchor.HTTP.send(get, HttpResponse.BodyHandlers.ofByteArray()).body();
}
cp
Copy a file into a folder of the same org.
def cp(file_id, destination_folder_id):
return call("cp", {
"file_id": file_id,
"destination_folder_id": destination_folder_id,
})
const cp = (fileId: string, destinationFolderId: string) =>
call('cp', {
file_id: fileId,
destination_folder_id: destinationFolderId,
});
static Task<JsonNode?> Cp(string fileId, string destinationFolderId) =>
Anchor.Call("cp", new
{
file_id = fileId,
destination_folder_id = destinationFolderId,
});
static JsonNode cp(String fileId, String destinationFolderId) throws Exception {
return Anchor.call("cp", Map.of(
"file_id", fileId,
"destination_folder_id", destinationFolderId));
}
mv
Move a file into another folder. Pass new_name to rename it.
def mv(file_id, destination_folder_id):
return call("mv", {
"file_id": file_id,
"destination_folder_id": destination_folder_id,
})
const mv = (fileId: string, destinationFolderId: string) =>
call('mv', {
file_id: fileId,
destination_folder_id: destinationFolderId,
});
static Task<JsonNode?> Mv(string fileId, string destinationFolderId) =>
Anchor.Call("mv", new
{
file_id = fileId,
destination_folder_id = destinationFolderId,
});
static JsonNode mv(String fileId, String destinationFolderId) throws Exception {
return Anchor.call("mv", Map.of(
"file_id", fileId,
"destination_folder_id", destinationFolderId));
}
rm_file
Delete a file the bot owns. It can't be undone.
def rm_file(file_id):
return call("rm_file", {
"file_id": file_id,
})
const rmFile = (fileId: string) =>
call('rm_file', {
file_id: fileId,
});
static Task<JsonNode?> RmFile(string fileId) =>
Anchor.Call("rm_file", new
{
file_id = fileId,
});
static JsonNode rmFile(String fileId) throws Exception {
return Anchor.call("rm_file", Map.of(
"file_id", fileId));
}
create_table
Create a table. Its first column is always record_id. Returns the table's id.
def create_table(folder_id, name):
return call("create_table", {
"folder_id": folder_id,
"name": name,
"description": "Run log",
"columns": [
{
"identifier": "record_id",
"display_name": "Record ID",
"type": "INTEGER",
"isUnique": True,
"isRequired": True,
},
{
"identifier": "message",
"display_name": "Message",
"type": "STRING",
"isUnique": False,
"isRequired": False,
},
],
})["table_id"]
const createTable = async (folderId: string, name: string): Promise<string> =>
(
await call('create_table', {
folder_id: folderId,
name,
description: 'Run log',
columns: [
{
identifier: 'record_id',
display_name: 'Record ID',
type: 'INTEGER',
isUnique: true,
isRequired: true,
},
{
identifier: 'message',
display_name: 'Message',
type: 'STRING',
isUnique: false,
isRequired: false,
},
],
})
).table_id;
static async Task<string> CreateTable(string folderId, string name) =>
(await Anchor.Call("create_table", new
{
folder_id = folderId,
name,
description = "Run log",
columns = new[]
{
new
{
identifier = "record_id",
display_name = "Record ID",
type = "INTEGER",
isUnique = true,
isRequired = true,
},
new
{
identifier = "message",
display_name = "Message",
type = "STRING",
isUnique = false,
isRequired = false,
},
},
}))!["table_id"]!.GetValue<string>();
static String createTable(String folderId, String name) throws Exception {
var columns = List.of(
Map.of(
"identifier", "record_id",
"display_name", "Record ID",
"type", "INTEGER",
"isUnique", true,
"isRequired", true),
Map.of(
"identifier", "message",
"display_name", "Message",
"type", "STRING",
"isUnique", false,
"isRequired", false));
return Anchor.call("create_table", Map.of(
"folder_id", folderId,
"name", name,
"description", "Run log",
"columns", columns)).get("table_id").asText();
}
describe_table
Get a table's columns and row count.
def describe_table(table_id):
return call("describe_table", {
"table_id": table_id,
})
const describeTable = (tableId: string) =>
call('describe_table', {
table_id: tableId,
});
static Task<JsonNode?> DescribeTable(string tableId) =>
Anchor.Call("describe_table", new
{
table_id = tableId,
});
static JsonNode describeTable(String tableId) throws Exception {
return Anchor.call("describe_table", Map.of(
"table_id", tableId));
}
query_table
Run one SELECT, INSERT, UPDATE or DELETE. Name a table by its id in double quotes.
def query_table(sql):
return call("query_table", {
"sql": sql,
})["rows"]
# query_table(f'INSERT INTO "{table_id}" (message) VALUES (\'started\')')
# query_table(f'SELECT * FROM "{table_id}"')
const queryTable = async (sql: string): Promise<any[]> =>
(
await call('query_table', {
sql,
})
).rows;
// await queryTable(`SELECT * FROM "${tableId}"`);
static async Task<JsonNode?> QueryTable(string sql) =>
(await Anchor.Call("query_table", new
{
sql,
}))!["rows"];
// await QueryTable($"SELECT * FROM \"{tableId}\"");
static JsonNode queryTable(String sql) throws Exception {
return Anchor.call("query_table", Map.of(
"sql", sql)).get("rows");
}
// queryTable("SELECT * FROM \"" + tableId + "\"");
add_table_column
Add a column to a table.
def add_table_column(table_id, identifier, display_name):
return call("add_table_column", {
"table_id": table_id,
"column": {
"identifier": identifier,
"display_name": display_name,
"type": "STRING",
"isUnique": False,
"isRequired": False,
},
})
const addTableColumn = (tableId: string, identifier: string, displayName: string) =>
call('add_table_column', {
table_id: tableId,
column: {
identifier,
display_name: displayName,
type: 'STRING',
isUnique: false,
isRequired: false,
},
});
static Task<JsonNode?> AddTableColumn(string tableId, string identifier, string displayName) =>
Anchor.Call("add_table_column", new
{
table_id = tableId,
column = new
{
identifier,
display_name = displayName,
type = "STRING",
isUnique = false,
isRequired = false,
},
});
static JsonNode addTableColumn(String tableId, String identifier, String displayName) throws Exception {
return Anchor.call("add_table_column", Map.of(
"table_id", tableId,
"column", Map.of(
"identifier", identifier,
"display_name", displayName,
"type", "STRING",
"isUnique", false,
"isRequired", false)));
}
delete_table_column
Delete a column and all its data. It can't be undone.
def delete_table_column(table_id, column_identifier):
return call("delete_table_column", {
"table_id": table_id,
"column_identifier": column_identifier,
})
const deleteTableColumn = (tableId: string, columnIdentifier: string) =>
call('delete_table_column', {
table_id: tableId,
column_identifier: columnIdentifier,
});
static Task<JsonNode?> DeleteTableColumn(string tableId, string columnIdentifier) =>
Anchor.Call("delete_table_column", new
{
table_id = tableId,
column_identifier = columnIdentifier,
});
static JsonNode deleteTableColumn(String tableId, String columnIdentifier) throws Exception {
return Anchor.call("delete_table_column", Map.of(
"table_id", tableId,
"column_identifier", columnIdentifier));
}
Security best practices
A bot key works from anywhere until it expires or is deleted, so treat it like a password.
- Never expose a key in client code. No browser, mobile app or anything you ship to users: keys run only on servers you control.
- Never commit a key to source control or GitHub, not even to a private repo.
- Never write a key to disk. No config files,
.envfiles or logs. - Keep it in a secret manager (AWS Secrets Manager, Google Secret Manager, Vault, GitHub Actions secrets) and hand it to the script as an environment variable, in memory.
- One key per script or environment, so you can delete one without stopping the rest. Give each an expiry, and rotate by adding the new key before deleting the old one.
- Ask for the shortest token that covers the run, and keep tokens in memory too.
- Share only what the bot needs, and as a viewer when it only reads.
- If a key leaks, delete it under Manage org → Membership → Bots. Its tokens stop working at once.
IP allowlisting
For enterprise customers, we can limit key exchange and other endpoints to the IP addresses you choose, so a key works only from your own network: an extra layer of network security on top of the key itself. This isn't available on the Free and Pro plans. Contact us to set it up.
Changes & announcements
The Anchor team may change these endpoints and tools, which can break a bot's script. If your organization has bots, we email its owners and managers about each change before it ships. Questions? Email support@anchor.cc.