Skill: venturecrew/evc-team-relay (version 1.1.2)

--- name: evc-team-relay version: 1.1.2 description: > Read and write Obsidian notes stored in EVC Team Relay collaborative vault. Use when agent needs to: read note content from a shared Obsidian vault, create or update documents, list available shared folders and documents, or search across shared vault content. Relay stores documents as Yjs CRDTs; this skill provides a REST interface to read/write their text content. metadata: openclaw: requires: env: - RELAY_CP_URL - RELAY_EMAIL - RELAY_PASSWORD bins: - curl - jq primaryEnv: RELAY_CP_URL homepage: https://github.com/entire-vc/evc-team-relay --- # EVC Team Relay REST API skill for reading and writing collaborative Obsidian vault documents via EVC Team Relay. ## Environment variables | Variable | Required | Description | |----------|----------|-------------| | `RELAY_CP_URL` | yes | Control plane URL, e.g. `https://cp.tr.entire.vc` | | `RELAY_EMAIL` | yes | User email for authentication | | `RELAY_PASSWORD` | yes | User password | | `RELAY_TOKEN` | no | JWT token (set via `export RELAY_TOKEN=$(scripts/auth.sh)`) | ## Quick start ```bash # 1. Authenticate — get a JWT token (stored in env var, not visible in ps) export RELAY_TOKEN=$(scripts/auth.sh) # 2. List shares to find available documents scripts/list-shares.sh # 3. Read a file from a folder share BY PATH (most common) scripts/read-file.sh <folder_share_id> "Marketing/plan.md" # 4. Create or update a file in a folder share scripts/upsert-file.sh <folder_share_id> "note.md" "# Content" # 5. List all files in a folder share scripts/list-files.sh <folder_share_id> # 6. Delete a file from a folder share scripts/delete-file.sh <folder_share_id> "old-note.md" # 7. Read a doc share (single document, share_id = doc_id) scripts/read.sh <share_id> # 8. Write to a doc share scripts/write.sh <share_id> <share_id> "# Updated content" ``` All scripts accept the token via `RELAY_TOKEN` env var (preferred) or as the first CLI argument (backward-compatible). ## Two kinds of shares | | Doc share | Folder share | |--|-----------|--------------| | **Contains** | Single document | Multiple files | | **doc_id** | Same as share_id | Each file has its own doc_id (in folder metadata) | | **Read** | `read.sh <share_id>` | **`read-file.sh <share_id> "path/to/file.md"`** | | **Write** | `write.sh <share_id> <share_id> <content>` | **`upsert-file.sh <share_id> "path" <content>`** | | **Delete** | N/A | `delete-file.sh <share_id> "path"` | **Most shares are folder shares.** Use `read-file.sh` and `upsert-file.sh` — they handle path resolution automatically. > **Warning**: `write.sh` does NOT work for folder shares — it writes content but does not register the file in folder metadata, so Obsidian will never see it. The script detects folder shares and refuses with an error. ## Scripts reference | Script | Purpose | Args | |--------|---------|------| | `auth.sh` | Get JWT token | — | | `list-shares.sh` | List all shares | `[kind] [owned_only]` | | `list-files.sh` | List files in folder share | `<share_id>` | | **`read-file.sh`** | **Read file by path (folder share)** | `<share_id> <file_path>` | | `read.sh` | Read by doc_id (low-level) | `<share_id> [doc_id]` | | **`upsert-file.sh`** | **Create/update file (folder share)** | `<share_id> <file_path> <content>` | | `write.sh` | Write by doc_id (doc shares only) | `<share_id> <doc_id> <content>` | | `delete-file.sh` | Delete file from folder share | `<share_id> <file_path>` | | `create-file.sh` | Create new file (low-level) | `<share_id> <file_path> <content>` | **Bold = recommended for most use cases.** All scripts use `RELAY_TOKEN` env var (or accept token as first arg). ## Authentication All API calls require a Bearer JWT token. Get one via login: ```bash curl -s -X POST "$RELAY_CP_URL/v1/auth/login" \ -H "Content-Type: application/json" \ -d '{"email": "'$RELAY_EMAIL'", "password": "'$RELAY_PASSWORD'"}' \ | jq -r '.access_token' ``` Response: ```json { "access_token": "eyJ...", "refresh_token": "...", "token_type": "bearer", "expires_in": 3600 } ``` Use the `access_token` as `Authorization: Bearer <token>` header on all subsequent requests. When the token expires (1 hour), refresh it: ```bash curl -s -X POST "$RELAY_CP_URL/v1/auth/refresh" \ -H "Content-Type: application/json" \ -d '{"refresh_token": "'$REFRESH_TOKEN'"}' ``` ## Listing shares Shares are the access units — each share maps to a document or folder in the Obsidian vault. ```bash curl -s "$RELAY_CP_URL/v1/shares" \ -H "Authorization: Bearer $TOKEN" | jq ``` Response (array): ```json [ { "id": "a1b2c3d4-...", "kind": "doc", "path": "Projects/meeting-notes.md", "visibility": "private", "is_owner": true, "user_role": null, "web_published": false }, { "id": "e5f6g7h8-...", "kind": "folder", "path": "Projects/", "visibility": "private", "is_owner": false, "user_role": "editor" } ] ``` Key fields: - **`id`** — share UUID, used as `share_id` in all operations - **`kind`** — `doc` (single file) or `folder` (directory) - **`path`** — Obsidian vault-relative path - **`user_role`** — `viewer` (read-only), `editor` (read-write), or `null` (owner) Filter options: `?kind=doc`, `?owned_only=true`, `?member_only=true`, `?skip=0&limit=50`. ## Listing files in a folder share ```bash scripts/list-files.sh <share_id> ``` Response: ```json { "doc_id": "e5f6g7h8-...", "files": { "meeting-notes.md": {"doc_id": "abc123-...", "type": "markdown"}, "project-plan.md": {"doc_id": "def456-...", "type": "markdown"} } } ``` Each key is the file's path within the folder. The **`doc_id`** field is the document identifier used for content operations. The `share_id` for content requests is always the folder share's ID. > **Note**: The API response uses `id` as the field name. This is the same as `doc_id` — use it wherever `doc_id` is needed. ## Reading files ### By path (recommended for folder shares) ```bash scripts/read-file.sh <folder_share_id> "Marketing/plan.md" ``` This resolves the path to a doc_id internally and returns: ```json { "doc_id": "abc123-...", "content": "# Marketing Plan\n\nContent here...", "format": "text", "path": "Marketing/plan.md" } ``` ### By doc_id (low-level) ```bash scripts/read.sh <share_id> [doc_id] [key] ``` For doc shares, omit doc_id (defaults to share_id). For folder shares, pass the file's doc_id from `list-files.sh`. ## Writing files ### Folder shares — use upsert-file.sh ```bash # Create or update — auto-detects which operation is needed scripts/upsert-file.sh <folder_share_id> "note.md" "# Updated content" # Pipe content from stdin echo "# Content" | scripts/upsert-file.sh <folder_share_id> "note.md" - ``` Response includes an `operation` field: `"created"` or `"updated"`. ### Doc shares — use write.sh ```bash scripts/write.sh <share_id> <share_id> "# Updated Notes" ``` > **write.sh refuses folder shares** — if you accidentally pass a folder share_id as doc_id, it detects this and exits with an error directing you to upsert-file.sh. ## Common workflows ### Read a specific note by path (most common) ```bash # If you know the folder share_id: scripts/read-file.sh <folder_share_id> "Marketing/docs/plan.md" # If you need to find the share first: scripts/list-shares.sh # find the folder share scripts/read-file.sh <share_id> "path/to/file.md" ``` ### Create or update a file ```bash # Always works, whether the file exists or not scripts/upsert-file.sh <folder_share_id> "note.md" "# Content" ``` ### Delete a file ```bash scripts/delete-file.sh <folder_share_id> "old-note.md" ``` ## Error codes | Status | Meaning | |--------|---------| | 400 | Invalid share_id format | | 401 | Missing or expired token — re-authenticate | | 403 | Insufficient permissions (viewer trying to write, or non-member) | | 404 | Share or file not found (check path spelling, use list-files.sh to verify) | | 422 | Missing required field (share_id, content) | | 502 | Relay server unavailable — retry later | ## Terminology | Term | Meaning | |------|---------| | `share_id` | UUID of a share (doc or folder). Used for ACL checks in all requests. | | `doc_id` | UUID of an individual document. For doc shares, equals share_id. For folder shares, each file has its own doc_id. | | `id` | Same as `doc_id` — the API response field name. Use interchangeably. | | `file_path` | Relative path within a folder share (e.g. `"Marketing/plan.md"`). | ## References - `references/api.md` — full API reference with all endpoints

path: README.md
content: # EVC Team Relay — OpenClaw Skill

[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![OpenClaw](https://img.shields.io/badge/OpenClaw-skill-FF5A2D)](https://github.com/openclaw/openclaw)
[![Entire VC](https://img.shields.io/badge/Entire_VC-toolbox-525769)](https://entire.vc)

**Give your AI agent read/write access to your Obsidian vault.**

> Your agent reads your notes, creates new ones, and stays in sync — all through the Team Relay API.

---

## What It Does

This [OpenClaw](https://github.com/openclaw/openclaw) skill connects your AI agent to Obsidian notes managed by [EVC Team Relay](https://github.com/entire-vc/evc-team-relay):

- **List** shared folders and documents
- **Read** note content as Markdown
- **Create** new notes in shared folders
- **Write** updates to existing notes
- **Delete** notes when no longer needed

Your agent works with the same notes your team edits in Obsidian — no copy/paste, no stale context.

---

## Use Cases

### AI-Assisted Knowledge Management
Your agent reads specs, updates status docs, creates meeting notes — all directly in your vault.

### Agent-to-Human Handoff
Agent writes analysis/research into a shared folder → you review in Obsidian → refine → agent picks up changes.

### Automated Documentation
Agent monitors code changes and keeps vault docs up to date. Combined with [Local Sync](https://github.com/entire-vc/evc-local-sync-plugin), it closes the loop: code → repo docs → vault → agent → code.

---

## Prerequisites

- [OpenClaw](https://github.com/openclaw/openclaw) installed
- A running [EVC Team Relay](https://github.com/entire-vc/evc-team-relay) instance (self-hosted or [hosted](https://entire.vc))
- A user account on the Relay control plane with access to shared folders
- `curl` and `jq` on the host

---

## Install

```bash
# Copy to OpenClaw skills directory
cp -r . ~/.openclaw/skills/evc-team-relay/
chmod +x ~/.openclaw/skills/evc-team-relay/scripts/*.sh
```

## Configure

Set environment variables in your OpenClaw config (`~/.openclaw/openclaw.json`):

```json
{
  "skills": {
    "entries": {
      "evc-team-relay": {
        "env": {
          "RELAY_CP_URL": "https://cp.yourdomain.com",
          "RELAY_EMAIL": "[REDACTED_SECRET]",
          "RELAY_PASSWORD": "your-password"
        }
      }
    }
  }
}
```

Then add the skill to your agent:

```json
{
  "agents": {
    "list": [
      {
        "id": "main",
        "skills": ["evc-team-relay"]
      }
    ]
  }
}
```

---

## Scripts

| Script | What it does |
|--------|-------------|
| `auth.sh` | Authenticate and get JWT token |
| `list-shares.sh` | List all accessible shared folders |
| `list-files.sh` | List files in a shared folder |
| `read.sh` | Read note content |
| `write.sh` | Update existing note |
| `create-file.sh` | Create new note in a folder |
| `delete-file.sh` | Delete a note |

---

## Quick Test

```bash
cd ~/.openclaw/skills/evc-team-relay

# Set env
export RELAY_CP_URL="https://cp.yourdomain.com"
export RELAY_EMAIL="[REDACTED_SECRET]"
export RELAY_PASSWORD="your-password"

# Authenticate
[REDACTED_SECRET] scripts/auth.sh)

# List shared folders
bash scripts/list-shares.sh "$TOKEN"

# List files in a folder
bash scripts/list-files.sh "$TOKEN" "<share_id>"

# Read a note
bash scripts/read.sh "$TOKEN" "<share_id>" "<doc_id>"

# Write a note
echo "# Hello from my agent" | bash scripts/write.sh "$TOKEN" "<share_id>" "<doc_id>" -
```

---

## How It Works

```
┌─────────────┐     REST API      ┌──────────────┐     Yjs CRDT      ┌──────────────┐
│  AI Agent   │ ◄──────────────► │  Team Relay  │ ◄──────────────► │   Obsidian   │
│ (OpenClaw)  │   read / write   │   Server     │    real-time     │    Client    │
└─────────────┘                  └──────────────┘      sync         └──────────────┘
```

The skill talks to Team Relay's REST API. Team Relay stores documents as Yjs CRDTs and syncs them to connected Obsidian clients in real-time. Changes made by the agent appear in Obsidian instantly — and vice versa.

---

## Part of the Entire VC Toolbox

| Product | What it does | Link |
|---------|-------------|------|
| **Team Relay** | Self-hosted collaboration server | [repo](https://github.com/entire-vc/evc-team-relay) |
| **Team Relay Plugin** | Obsidian plugin for Team Relay | [repo](https://github.com/entire-vc/evc-team-relay-obsidian-plugin) |
| **Relay MCP** | MCP server for Claude Code, Codex, OpenCode | [repo](https://github.com/entire-vc/evc-team-relay-mcp) |
| **OpenClaw Skill** ← you are here | OpenClaw agent skill (bash) | this repo |
| **Local Sync** | Vault ↔ AI dev tools sync | [repo](https://github.com/entire-vc/evc-local-sync-plugin) |
| **Spark MCP** | MCP server for AI workflow catalog | [repo](https://github.com/entire-vc/evc-spark-mcp) |

## Community

- 🌐 [entire.vc](https://entire.vc)
- 💬 [Discussions](https://github.com/entire-vc/.github/discussions)
- 📧 [REDACTED_SECRET]

## License

MIT — Copyright (c) 2026 Entire VC


path: scripts/upsert-file.sh
content: #!/usr/bin/env bash
# Create or update a file in a folder share (smart upsert).
# Automatically detects whether the file already exists:
#   - New file    → POST /files (creates document + registers in folder metadata)
#   - Existing    → PUT /content (updates document content)
#
# Usage: scripts/upsert-file.sh <folder_share_id> <file_path> <content>
#    or: echo "content" | scripts/upsert-file.sh <folder_share_id> <file_path> -
# Args:
#   folder_share_id — folder share UUID
#   file_path       — file name within the folder (e.g. "notes.md")
#   content         — text content to write, or "-" to read from stdin
# Env: RELAY_CP_URL, RELAY_TOKEN (or pass token as first arg)
# Output: JSON with doc_id, path, length, and operation ("created" or "updated")
set -euo pipefail

: "${RELAY_CP_URL:?Set RELAY_CP_URL}"

# [REDACTED_SECRET] RELAY_TOKEN env var, fall back to $1 (backward-compatible)
if [ -n "${RELAY_TOKEN:-}" ]; then
  [REDACTED_SECRET]"
else
  [REDACTED_SECRET] upsert-file.sh [token] <folder_share_id> <file_path> <content> (or set RELAY_TOKEN)}"
  shift
fi
FOLDER_SHARE_ID="${1:?Usage: upsert-file.sh <folder_share_id> <file_path> <content>}"
FILE_PATH="${2:?Usage: upsert-file.sh <folder_share_id> <file_path> <content>}"
CONTENT_ARG="${3:?Usage: upsert-file.sh <folder_share_id> <file_path> <content>}"

if [ "$CONTENT_ARG" = "-" ]; then
  CONTENT=$(cat)
else
  CONTENT="$CONTENT_ARG"
fi

# Check if file already exists in the folder's metadata
FILES_JSON=$(curl -sf "${RELAY_CP_URL}/v1/documents/${FOLDER_SHARE_ID}/files?share_id=${FOLDER_SHARE_ID}" \
  -H "Authorization: Bearer $TOKEN")

EXISTING_DOC_ID=$(echo "$FILES_JSON" | jq -r --arg path "$FILE_PATH" '.files[$path].id // empty')

if [ -n "$EXISTING_DOC_ID" ]; then
  # File exists — update content via PUT
  PAYLOAD=$(jq -n \
    --arg sid "$FOLDER_SHARE_ID" \
    --arg content "$CONTENT" \
    '{share_id: $sid, content: $content, key: "contents"}')

  RESULT=$(curl -sf -X PUT "${RELAY_CP_URL}/v1/documents/${EXISTING_DOC_ID}/content" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "$PAYLOAD")

  echo "$RESULT" | jq --arg path "$FILE_PATH" '. + {path: $path, operation: "updated"}'
else
  # File doesn't exist — create via POST (registers in filemeta_v0)
  PAYLOAD=$(jq -n \
    --arg sid "$FOLDER_SHARE_ID" \
    --arg path "$FILE_PATH" \
    --arg content "$CONTENT" \
    '{share_id: $sid, path: $path, content: $content}')

  RESULT=$(curl -sf -X POST "${RELAY_CP_URL}/v1/documents/${FOLDER_SHARE_ID}/files" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "$PAYLOAD")

  echo "$RESULT" | jq '. + {operation: "created"}'
fi


path: scripts/read-file.sh
content: #!/usr/bin/env bash
# Read a file from a folder share by its path.
# Resolves file path → doc_id automatically via list-files, then reads content.
#
# Usage: scripts/read-file.sh <folder_share_id> <file_path> [key]
# Args:
#   folder_share_id — folder share UUID
#   file_path       — file path within the folder (e.g. "Marketing/plan.md")
#   key             — (optional) Yjs key, defaults to "contents"
# Env: RELAY_CP_URL, RELAY_TOKEN (or pass token as first arg)
# Output: JSON with doc_id, path, content
set -euo pipefail

: "${RELAY_CP_URL:?Set RELAY_CP_URL}"

# [REDACTED_SECRET] RELAY_TOKEN env var, fall back to $1 (backward-compatible)
if [ -n "${RELAY_TOKEN:-}" ]; then
  [REDACTED_SECRET]"
else
  [REDACTED_SECRET] read-file.sh [token] <folder_share_id> <file_path> [key] (or set RELAY_TOKEN)}"
  shift
fi
FOLDER_SHARE_ID="${1:?Usage: read-file.sh <folder_share_id> <file_path> [key]}"
FILE_PATH="${2:?Usage: read-file.sh <folder_share_id> <file_path> [key]}"
KEY="${3:-contents}"

# Step 1: List files to resolve path → doc_id
FILES_JSON=$(curl -sf "${RELAY_CP_URL}/v1/documents/${FOLDER_SHARE_ID}/files?share_id=${FOLDER_SHARE_ID}" \
  -H "Authorization: Bearer $TOKEN")

DOC_ID=$(echo "$FILES_JSON" | jq -r --arg path "$FILE_PATH" '.files[$path].id // empty')

if [ -z "$DOC_ID" ]; then
  echo "Error: file not found in folder share: $FILE_PATH" >&2
  echo "Available files:" >&2
  echo "$FILES_JSON" | jq -r '.files | keys[]' >&2
  exit 1
fi

# Step 2: Read content using resolved doc_id
CONTENT_JSON=$(curl -sf "${RELAY_CP_URL}/v1/documents/${DOC_ID}/content?share_id=${FOLDER_SHARE_ID}&key=${KEY}" \
  -H "Authorization: Bearer $TOKEN")

# Return enriched response with path included
echo "$CONTENT_JSON" | jq --arg path "$FILE_PATH" '. + {path: $path}'


path: scripts/read.sh
content: #!/usr/bin/env bash
# Read document content from Relay.
# Usage: scripts/read.sh <share_id> [doc_id] [key]
# Args:
#   share_id — share UUID (for ACL check)
#   doc_id   — (optional) document ID, defaults to share_id
#   key      — (optional) Yjs key, defaults to "contents"
# Env: RELAY_CP_URL, RELAY_TOKEN (or pass token as first arg)
# Output: document content as JSON (doc_id, content, format)
set -euo pipefail

: "${RELAY_CP_URL:?Set RELAY_CP_URL}"

# [REDACTED_SECRET] RELAY_TOKEN env var, fall back to $1 (backward-compatible)
if [ -n "${RELAY_TOKEN:-}" ]; then
  [REDACTED_SECRET]"
else
  [REDACTED_SECRET] read.sh [token] <share_id> [doc_id] [key] (or set RELAY_TOKEN)}"
  shift
fi
SHARE_ID="${1:?Usage: read.sh <share_id> [doc_id] [key]}"
DOC_ID="${2:-$SHARE_ID}"
KEY="${3:-contents}"

curl -sf "${RELAY_CP_URL}/v1/documents/${DOC_ID}/content?share_id=${SHARE_ID}&key=${KEY}" \
  -H "Authorization: Bearer $TOKEN" | jq '.'


path: scripts/create-file.sh
content: #!/usr/bin/env bash
# Create a new file inside a folder share.
# Usage: scripts/create-file.sh <folder_share_id> <file_path> <content>
#    or: echo "content" | scripts/create-file.sh <folder_share_id> <file_path> -
# Args:
#   folder_share_id — folder share UUID (used as both doc_id and share_id)
#   file_path       — file name within the folder (e.g. "notes.md")
#   content         — text content to write, or "-" to read from stdin
# Env: RELAY_CP_URL, RELAY_TOKEN (or pass token as first arg)
# Output: JSON with doc_id, path, status, length
set -euo pipefail

: "${RELAY_CP_URL:?Set RELAY_CP_URL}"

# [REDACTED_SECRET] RELAY_TOKEN env var, fall back to $1 (backward-compatible)
if [ -n "${RELAY_TOKEN:-}" ]; then
  [REDACTED_SECRET]"
else
  [REDACTED_SECRET] create-file.sh [token] <folder_share_id> <file_path> <content> (or set RELAY_TOKEN)}"
  shift
fi
FOLDER_SHARE_ID="${1:?Usage: create-file.sh <folder_share_id> <file_path> <content>}"
FILE_PATH="${2:?Usage: create-file.sh <folder_share_id> <file_path> <content>}"
CONTENT_ARG="${3:?Usage: create-file.sh <folder_share_id> <file_path> <content>}"

if [ "$CONTENT_ARG" = "-" ]; then
  CONTENT=$(cat)
else
  CONTENT="$CONTENT_ARG"
fi

# Build JSON payload with jq to handle escaping
PAYLOAD=$(jq -n \
  --arg sid "$FOLDER_SHARE_ID" \
  --arg path "$FILE_PATH" \
  --arg content "$CONTENT" \
  '{share_id: $sid, path: $path, content: $content}')

curl -sf -X POST "${RELAY_CP_URL}/v1/documents/${FOLDER_SHARE_ID}/files" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "$PAYLOAD" | jq '.'


path: scripts/auth.sh
content: #!/usr/bin/env bash
# Authenticate with Relay Control Plane and print the access token.
# Usage: scripts/auth.sh
# Env: RELAY_CP_URL, RELAY_EMAIL, RELAY_PASSWORD
# Output: access_token (plain text, no newline) — suitable for $()
set -euo pipefail

: "${RELAY_CP_URL:?Set RELAY_CP_URL (e.g. https://cp.tr.entire.vc)}"
: "${RELAY_EMAIL:?Set RELAY_EMAIL}"
: "${RELAY_PASSWORD:?Set RELAY_PASSWORD}"

resp=$(curl -sf -X POST "${RELAY_CP_URL}/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d "{\"email\": \"${RELAY_EMAIL}\", \"password\": \"${RELAY_PASSWORD}\"}")

[REDACTED_SECRET] "$resp" | jq -r '.access_token')
if [ -z "$token" ] || [ "$token" = "null" ]; then
  echo "Authentication failed" >&2
  echo "$resp" >&2
  exit 1
fi

echo -n "$token"


path: scripts/write.sh
content: #!/usr/bin/env bash
# Write document content to Relay (doc shares only).
#
# IMPORTANT: This script detects folder shares and refuses to write.
# For folder shares, use upsert-file.sh instead — it properly registers
# files in folder metadata so Obsidian can see them.
#
# Usage: scripts/write.sh <share_id> <doc_id> <content> [key]
#    or: echo "content" | scripts/write.sh <share_id> <doc_id> - [key]
# Args:
#   share_id — share UUID (for ACL check)
#   doc_id   — document ID
#   content  — text content to write, or "-" to read from stdin
#   key      — (optional) Yjs key, defaults to "contents"
# Env: RELAY_CP_URL, RELAY_TOKEN (or pass token as first arg)
set -euo pipefail

: "${RELAY_CP_URL:?Set RELAY_CP_URL}"

# [REDACTED_SECRET] RELAY_TOKEN env var, fall back to $1 (backward-compatible)
if [ -n "${RELAY_TOKEN:-}" ]; then
  [REDACTED_SECRET]"
else
  [REDACTED_SECRET] write.sh [token] <share_id> <doc_id> <content> [key] (or set RELAY_TOKEN)}"
  shift
fi
SHARE_ID="${1:?Usage: write.sh <share_id> <doc_id> <content> [key]}"
DOC_ID="${2:?Usage: write.sh <share_id> <doc_id> <content> [key]}"
CONTENT_ARG="${3:?Usage: write.sh <share_id> <doc_id> <content> [key]}"
KEY="${4:-contents}"

# Safety check: detect folder shares and refuse
# If share_id == doc_id AND the share has a files endpoint, it's likely a folder share
# being used incorrectly (user passing share_id as doc_id)
if [ "$SHARE_ID" = "$DOC_ID" ]; then
  # Check if this is a folder share by trying to list files
  FILES_CHECK=$(curl -sf "${RELAY_CP_URL}/v1/documents/${SHARE_ID}/files?share_id=${SHARE_ID}" \
    -H "Authorization: Bearer $TOKEN" 2>/dev/null || echo "")
  if [ -n "$FILES_CHECK" ]; then
    FILE_COUNT=$(echo "$FILES_CHECK" | jq '.files | length' 2>/dev/null || echo "0")
    if [ "$FILE_COUNT" -gt "0" ] || echo "$FILES_CHECK" | jq -e '.files' >/dev/null 2>&1; then
      echo "Error: this looks like a folder share (has file metadata)." >&2
      echo "write.sh does NOT work for folder shares — files won't appear in Obsidian." >&2
      echo "" >&2
      echo "Use upsert-file.sh instead:" >&2
      echo "  scripts/upsert-file.sh \"$TOKEN\" \"$SHARE_ID\" \"filename.md\" \"content\"" >&2
      exit 1
    fi
  fi
fi

if [ "$CONTENT_ARG" = "-" ]; then
  CONTENT=$(cat)
else
  CONTENT="$CONTENT_ARG"
fi

# Build JSON payload with jq to handle escaping
PAYLOAD=$(jq -n \
  --arg sid "$SHARE_ID" \
  --arg content "$CONTENT" \
  --arg key "$KEY" \
  '{share_id: $sid, content: $content, key: $key}')

curl -sf -X PUT "${RELAY_CP_URL}/v1/documents/${DOC_ID}/content" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "$PAYLOAD" | jq '.'


path: scripts/list-files.sh
content: #!/usr/bin/env bash
# List files in a folder share from Relay.
# Usage: scripts/list-files.sh <share_id>
# Args:
#   share_id — folder share UUID (used as both doc_id and share_id for ACL check)
# Env: RELAY_CP_URL, RELAY_TOKEN (or pass token as first arg)
# Output: JSON with doc_id and files map (path -> metadata)
set -euo pipefail

: "${RELAY_CP_URL:?Set RELAY_CP_URL}"

# [REDACTED_SECRET] RELAY_TOKEN env var, fall back to $1 (backward-compatible)
if [ -n "${RELAY_TOKEN:-}" ]; then
  [REDACTED_SECRET]"
else
  [REDACTED_SECRET] list-files.sh [token] <share_id> (or set RELAY_TOKEN)}"
  shift
fi
SHARE_ID="${1:?Usage: list-files.sh <share_id>}"

curl -sf "${RELAY_CP_URL}/v1/documents/${SHARE_ID}/files?share_id=${SHARE_ID}" \
  -H "Authorization: Bearer $TOKEN" | jq '.'


path: scripts/list-shares.sh
content: #!/usr/bin/env bash
# List accessible shares from Relay Control Plane.
# Usage: scripts/list-shares.sh [kind] [owned_only]
# Args:
#   kind        — (optional) "doc" or "folder"
#   owned_only  — (optional) "true" to show only owned shares
# Env: RELAY_CP_URL, RELAY_TOKEN (or pass token as first arg)
set -euo pipefail

: "${RELAY_CP_URL:?Set RELAY_CP_URL}"

# [REDACTED_SECRET] RELAY_TOKEN env var, fall back to $1 (backward-compatible)
if [ -n "${RELAY_TOKEN:-}" ]; then
  [REDACTED_SECRET]"
else
  [REDACTED_SECRET] list-shares.sh [token] [kind] [owned_only] (or set RELAY_TOKEN)}"
  shift
fi
KIND="${1:-}"
OWNED="${2:-}"

URL="${RELAY_CP_URL}/v1/shares"
PARAMS=""
[ -n "$KIND" ] && PARAMS="${PARAMS}&kind=${KIND}"
[ -n "$OWNED" ] && PARAMS="${PARAMS}&owned_only=${OWNED}"
[ -n "$PARAMS" ] && URL="${URL}?${PARAMS:1}"

curl -sf "$URL" -H "Authorization: Bearer $TOKEN" | jq '.'


path: scripts/delete-file.sh
content: #!/usr/bin/env bash
# Delete a file from a folder share.
# Usage: scripts/delete-file.sh <folder_share_id> <file_path>
# Args:
#   folder_share_id — folder share UUID
#   file_path       — file name within the folder (e.g. "notes.md")
# Env: RELAY_CP_URL, RELAY_TOKEN (or pass token as first arg)
# Output: JSON with path and status
set -euo pipefail

: "${RELAY_CP_URL:?Set RELAY_CP_URL}"

# [REDACTED_SECRET] RELAY_TOKEN env var, fall back to $1 (backward-compatible)
if [ -n "${RELAY_TOKEN:-}" ]; then
  [REDACTED_SECRET]"
else
  [REDACTED_SECRET] delete-file.sh [token] <folder_share_id> <file_path> (or set RELAY_TOKEN)}"
  shift
fi
FOLDER_SHARE_ID="${1:?Usage: delete-file.sh <folder_share_id> <file_path>}"
FILE_PATH="${2:?Usage: delete-file.sh <folder_share_id> <file_path>}"

# URL-encode the file path for safety (using jq to avoid python3 dependency)
ENCODED_PATH=$(printf '%s' "$FILE_PATH" | jq -sRr @uri)

curl -sf -X DELETE "${RELAY_CP_URL}/v1/documents/${FOLDER_SHARE_ID}/files/${ENCODED_PATH}?share_id=${FOLDER_SHARE_ID}" \
  -H "Authorization: Bearer $TOKEN" | jq '.'


path: references/api.md
content: # EVC Team Relay — API Reference

Base URL: `$RELAY_CP_URL` (e.g. `https://cp.tr.entire.vc`)

All endpoints require `Authorization: Bearer <token>` unless noted otherwise.

---

## Authentication

### POST /v1/auth/login

Login with email and password.

**No auth required.**

Request:
```json
{"email": "[REDACTED_SECRET]", "password": "secretpass"}
```

Response `200`:
```json
{
  "access_token": "eyJ...",
  "refresh_token": "abc123...",
  "token_type": "bearer",
  "expires_in": 3600
}
```

Errors: `400` (invalid credentials), `429` (rate limit: 10/min).

### POST /v1/auth/refresh

Refresh an expired access token.

Request:
```json
{"refresh_token": "abc123..."}
```

Response `200`: same as login response with new tokens.

### GET /v1/auth/me

Get current user information.

Response `200`:
```json
{
  "id": "uuid",
  "email": "[REDACTED_SECRET]",
  "is_admin": false,
  "is_active": true,
  "created_at": "2026-01-01T00:00:00Z"
}
```

---

## Shares

### GET /v1/shares

List shares accessible to the authenticated user.

Query parameters:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `kind` | string | — | Filter: `doc` or `folder` |
| `owned_only` | bool | false | Only shares owned by user |
| `member_only` | bool | false | Only shares where user is member (not owner) |
| `skip` | int | 0 | Pagination offset |
| `limit` | int | 50 | Max results (1-100) |

Response `200` (array):
```json
[
  {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "kind": "doc",
    "path": "Projects/meeting-notes.md",
    "visibility": "private",
    "owner_user_id": "user-uuid",
    "created_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-02-19T08:00:00Z",
    "is_owner": true,
    "user_role": null,
    "web_published": false,
    "web_slug": null
  }
]
```

Fields:
- `id` — share UUID. For `doc` shares, also used as `doc_id` and `share_id`.
- `kind` — `doc` (single document) or `folder` (directory of documents).
- `path` — vault-relative path (e.g. `Notes/daily.md` or `Projects/`).
- `visibility` — `private`, `public`, or `protected`.
- `is_owner` — true if current user owns this share.
- `user_role` — `viewer`, `editor`, or `null` (owner/non-member).

### GET /v1/shares/{id}

Get share details by ID.

Response `200`: single share object (same schema as list item, plus `password_protected` field).

### POST /v1/shares

Create a new share.

Request:
```json
{
  "kind": "doc",
  "path": "Notes/new-note.md",
  "visibility": "private"
}
```

Response `201`: created share object.

Rate limit: 20/min.

---

## Documents

### GET /v1/documents/{doc_id}/content

Read plain text content from a Yjs document.

Path parameters:
- `doc_id` — document identifier (for `doc` shares, equals `share_id`)

Query parameters:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `share_id` | UUID | required | Share UUID for ACL check |
| `key` | string | `contents` | Yjs shared type key name |

Response `200`:
```json
{
  "doc_id": "a1b2c3d4-...",
  "content": "# Meeting Notes\n\nContent here...",
  "format": "text"
}
```

Access: viewer, editor, or owner of the share.

Errors:
- `400` — invalid `share_id` format
- `401` — no/expired token
- `403` — no read access
- `404` — share not found
- `422` — missing `share_id`
- `502` — relay server unavailable

### GET /v1/documents/{doc_id}/files

List files stored in a folder share's `filemeta_v0` Y.Map.

Path parameters:
- `doc_id` — document identifier (for folder shares, equals `share_id`)

Query parameters:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `share_id` | UUID | required | Share UUID for ACL check |

Response `200`:
```json
{
  "doc_id": "e5f6g7h8-...",
  "files": {
    "meeting-notes.md": {"id": "abc123", "type": "markdown", "hash": "h1a2b3c4"},
    "project-plan.md": {"id": "def456", "type": "markdown", "hash": "h5e6f7g8"}
  }
}
```

Fields:
- `doc_id` — the document ID queried (same as `share_id` for folder shares).
- `files` — map of virtual file paths to metadata dicts. Keys are vault-relative paths within the folder. Values are Y.Map entries (shape depends on plugin version; typically includes `id`, `type`, `hash`).

Use each file's `id` value as the `doc_id` for `GET /v1/documents/{doc_id}/content` to read its text.

Returns an empty `files` dict if the folder share has no files synced yet.

Access: viewer, editor, or owner of the share.

Errors:
- `400` — invalid `share_id` format
- `401` — no/expired token
- `403` — no read access
- `404` — share not found
- `422` — missing `share_id`
- `502` — relay server unavailable

### POST /v1/documents/{doc_id}/files

Create a new file inside a folder share. Generates a document, writes content, and registers the file in the folder's `filemeta_v0` Y.Map.

Path parameters:
- `doc_id` — folder share's document ID (equals `share_id` for folder shares)

Request body:
```json
{
  "share_id": "e5f6g7h8-...",
  "path": "new-note.md",
  "content": "# New Note\n\nContent here.",
  "key": "contents"
}
```

Fields:
- `share_id` (required) — folder share UUID for ACL check.
- `path` (required) — file name/path within the folder (e.g. `notes.md`).
- `content` (optional, default `""`) — initial text content.
- `key` (optional, default `contents`) — Yjs shared type key.

Response `201`:
```json
{
  "doc_id": "generated-uuid",
  "path": "new-note.md",
  "status": "ok",
  "length": 30
}
```

The returned `doc_id` can be used with `GET/PUT /documents/{doc_id}/content` for subsequent reads/writes. The file will appear in the Obsidian plugin on next sync.

Access: editor or owner only. Only works on `folder` shares (not `doc` shares).

Errors:
- `400` — invalid `share_id`, not a folder share, or empty path
- `401` — no/expired token
- `403` — no write access
- `404` — share not found
- `422` — missing required fields
- `502` — relay server unavailable

### DELETE /v1/documents/{doc_id}/files/{file_path}

Delete a file from a folder share. Removes the entry from `filemeta_v0` Y.Map.

Path parameters:
- `doc_id` — folder share's document ID
- `file_path` — file name/path to delete (e.g. `notes.md`)

Query parameters:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `share_id` | UUID | required | Share UUID for ACL check |

Response `200`:
```json
{
  "path": "notes.md",
  "status": "ok"
}
```

Access: editor or owner only. Only works on `folder` shares.

Errors:
- `400` — invalid `share_id` or not a folder share
- `401` — no/expired token
- `403` — no write access
- `404` — file not found in folder, or share not found
- `502` — relay server unavailable

### PUT /v1/documents/{doc_id}/content

Write (replace) text content in a Yjs document.

Path parameters:
- `doc_id` — document identifier

Request body:
```json
{
  "share_id": "a1b2c3d4-...",
  "content": "# Updated Content\n\nNew text here.",
  "key": "contents"
}
```

Fields:
- `share_id` (required) — share UUID for ACL check.
- `content` (required) — full document text. Replaces the entire document.
- `key` (optional, default `contents`) — Yjs shared type key.

Response `200`:
```json
{
  "doc_id": "a1b2c3d4-...",
  "status": "ok",
  "length": 42
}
```

Access: editor or owner only. Viewers get `403`.

Errors: same as GET, plus `422` if `content` is missing.

---

## Health

### GET /health

Health check endpoint. **No auth required.**

Response `200`:
```json
{"ok": true}
```

### GET /server/info

Server metadata. **No auth required.**

Response `200`:
```json
{
  "name": "Relay Server",
  "version": "2.9.0",
  "relay_url": "wss://...",
  "features": { ... }
}
```

---

## Rate limits

| Endpoint | Limit |
|----------|-------|
| POST /v1/auth/login | 10/min |
| POST /v1/shares | 20/min |
| POST /v1/tokens/relay | 30/min |

Rate-limited responses return `429 Too Many Requests`.

## Error format

All errors return JSON:
```json
{"detail": "Error description"}
```

For validation errors (422):
```json
{
  "detail": [
    {"loc": ["body", "field"], "msg": "field required", "type": "value_error.missing"}
  ]
}
```


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)