diff --git a/EXAMPLE_SKILL.md b/EXAMPLE_SKILL.md
index 1666a45..79d0929 100644
--- a/EXAMPLE_SKILL.md
+++ b/EXAMPLE_SKILL.md
@@ -1,317 +1,10 @@
---
name: coplan
-description: "Upload, edit, and comment on plans via the CoPlan API. Use when asked to create plans, edit plan documents, manage edit leases, or leave review comments on plans."
+description: "Create, edit, and review engineering design docs via the CoPlan API."
---
-# CoPlan API
+# CoPlan
-Interact with the CoPlan app to create, read, edit, and comment on plan documents.
+Read the full API instructions at: BASE_URL/agent-instructions
-## Setup
-
-Credentials are stored at `~/.config/planning-department/credentials.json`:
-
-```json
-{
- "base_url": "http://localhost:3000",
- "token": "your-token-here"
-}
-```
-
-On first use:
-
-1. Read `~/.config/planning-department/credentials.json` to get `token` and `base_url`.
-2. If the file does not exist, tell the user: "Go to **Settings → API Tokens** in the CoPlan web UI to create a token." Ask for the token and base URL, then save to `~/.config/planning-department/credentials.json` with `chmod 600`.
-3. If any API call returns 401, the token is invalid or revoked. Prompt the user to create a new token in Settings and update the credentials file.
-
-Use the values from the credentials file in all API calls below. Read the file once at the start of the session:
-
-```bash
-PLANNING_BASE_URL=$(cat ~/.config/planning-department/credentials.json | jq -r '.base_url')
-PLANNING_API_TOKEN=$(cat ~/.config/planning-department/credentials.json | jq -r '.token')
-```
-
-## API Reference
-
-All requests use `Authorization: Bearer $PLANNING_API_TOKEN` header.
-
-### List Plans
-
-```bash
-curl -s -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- "$PLANNING_BASE_URL/api/v1/plans" | jq .
-```
-
-Optional query param: `?status=considering`
-
-### Get Plan
-
-```bash
-curl -s -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID" | jq .
-```
-
-Returns: `id`, `title`, `status`, `current_content` (markdown), `current_revision`.
-
-### Create Plan
-
-```bash
-curl -s -X POST \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"title": "My Plan", "content": "# My Plan\n\nContent here."}' \
- "$PLANNING_BASE_URL/api/v1/plans" | jq .
-```
-
-### Update Plan
-
-Update plan metadata (title, status, tags). Only fields included in the request body are changed.
-
-```bash
-curl -s -X PATCH \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"status": "considering"}' \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID" | jq .
-```
-
-Allowed fields: `title` (string), `status` (string — one of `brainstorm`, `considering`, `developing`, `live`, `abandoned`), `tags` (array of strings).
-
-### Get Versions
-
-```bash
-curl -s -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/versions" | jq .
-```
-
-### Get Comments
-
-```bash
-curl -s -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/comments" | jq .
-```
-
-## Editing Plans (Lease + Operations)
-
-Editing requires three steps: acquire lease → apply operations → release lease.
-
-### 1. Acquire Edit Lease
-
-```bash
-LEASE_RESPONSE=$(curl -s -X POST \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"lease_token": "'$(openssl rand -hex 32)'"}' \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/lease")
-echo "$LEASE_RESPONSE" | jq .
-LEASE_TOKEN=$(echo "$LEASE_RESPONSE" | jq -r '.lease_token')
-```
-
-Leases expire after 5 minutes. Renew with PATCH, release with DELETE.
-
-### 2. Apply Operations
-
-```bash
-curl -s -X POST \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "lease_token": "'$LEASE_TOKEN'",
- "base_revision": 1,
- "change_summary": "Updated goals section",
- "operations": [
- {
- "op": "replace_exact",
- "old_text": "old text here",
- "new_text": "new text here",
- "count": 1
- }
- ]
- }' \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/operations" | jq .
-```
-
-**Important:** Set `base_revision` to the plan's `current_revision`. Returns 409 if stale.
-
-### 3. Release Edit Lease
-
-```bash
-curl -s -X DELETE \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"lease_token": "'$LEASE_TOKEN'"}' \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/lease"
-```
-
-### Renew Lease (if needed for long edits)
-
-```bash
-curl -s -X PATCH \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"lease_token": "'$LEASE_TOKEN'"}' \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/lease" | jq .
-```
-
-## Available Operations
-
-### replace_exact
-
-Find and replace exact text. Fails if text not found or count exceeded.
-
-```json
-{
- "op": "replace_exact",
- "old_text": "We should use MySQL",
- "new_text": "We should use PostgreSQL",
- "count": 1
-}
-```
-
-### insert_under_heading
-
-Insert content after a markdown heading. Fails if heading not found or ambiguous.
-
-```json
-{
- "op": "insert_under_heading",
- "heading": "## Testing Strategy",
- "content": "- Add integration tests\n- Mock external providers"
-}
-```
-
-### delete_paragraph_containing
-
-Delete the paragraph containing a needle string. Fails if 0 or >1 paragraphs match.
-
-```json
-{
- "op": "delete_paragraph_containing",
- "needle": "This approach is deprecated"
-}
-```
-
-## Commenting
-
-### Create Comment Thread
-
-```bash
-curl -s -X POST \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "body_markdown": "This section needs more detail.",
- "anchor_text": "the exact text you are commenting on",
- "agent_name": "Amp"
- }' \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/comments" | jq .
-```
-
-- `anchor_text`: the exact text from the plan content that this comment is about. It will be highlighted in the UI and the comment will be anchored to it.
-- Omit `anchor_text` for a general (non-anchored) comment.
-- `agent_name`: optional identifier for the agent (e.g., `"Amp"`, `"Claude"`). Displayed in the UI as "User Name (Agent Name)".
-
-### Reply to Thread
-
-```bash
-curl -s -X POST \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"body_markdown": "Good point, I will address this.", "agent_name": "Amp"}' \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/comments/$THREAD_ID/reply" | jq .
-```
-
-### Resolve Thread
-
-Mark a comment thread as resolved (addressed by the plan author or thread creator).
-
-```bash
-curl -s -X PATCH \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/comments/$THREAD_ID/resolve" | jq .
-```
-
-### Dismiss Thread
-
-Dismiss a comment thread (plan author only — for comments that are out of scope or not applicable).
-
-```bash
-curl -s -X PATCH \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/comments/$THREAD_ID/dismiss" | jq .
-```
-
-## Reviewing a Plan
-
-When asked to review a plan (given a plan URL or ID), follow this workflow:
-
-### 1. Read the Plan and Comments
-
-Fetch the plan content and all comment threads:
-
-```bash
-curl -s -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID" | jq .
-
-curl -s -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/comments" | jq .
-```
-
-### 2. Triage Comments
-
-Review each open comment thread and categorize it:
-
-- **Just do it** — Clear, actionable feedback that can be applied directly (typos, clarifications, missing details where the fix is obvious). Apply these edits without asking.
-- **Confirm first** — Substantive changes where the right fix is clear but the scope is large enough to warrant confirmation. Summarize the proposed change and ask the user before applying.
-- **Discuss** — Ambiguous feedback, disagreements, or comments that require a design decision. Present these to the user for discussion — do not attempt to resolve them.
-
-### 3. Present Summary
-
-Before making any changes, present a summary to the user:
-
-> **Plan Review: [Title]**
->
-> **Will apply (N comments):** [list each with a one-line summary of the change]
->
-> **Need confirmation (N comments):** [list each with the proposed change]
->
-> **For discussion (N comments):** [list each with the question/issue]
->
-> Shall I proceed with the "just do it" edits?
-
-### 4. Apply Edits
-
-For approved changes:
-
-1. Acquire an edit lease
-2. Apply operations (use `replace_exact`, `insert_under_heading`, or `delete_paragraph_containing`)
-3. Release the lease
-4. Resolve each comment thread that was addressed
-
-### 5. Resolve Threads
-
-After applying edits, resolve the comment threads that were addressed:
-
-```bash
-curl -s -X PATCH \
- -H "Authorization: Bearer $PLANNING_API_TOKEN" \
- "$PLANNING_BASE_URL/api/v1/plans/$PLAN_ID/comments/$THREAD_ID/resolve" | jq .
-```
-
-## Typical Workflow
-
-1. **Read** the plan: `GET /api/v1/plans/:id`
-2. **Acquire lease**: `POST /api/v1/plans/:id/lease`
-3. **Apply operations**: `POST /api/v1/plans/:id/operations` (can call multiple times while lease is held)
-4. **Release lease**: `DELETE /api/v1/plans/:id/lease`
-5. **Comment** on changes: `POST /api/v1/plans/:id/comments`
-
-## Error Codes
-
-| Code | Meaning |
-|------|---------|
-| 401 | Invalid or expired API token |
-| 403 | Not authorized for this action |
-| 404 | Plan not found (or no access) |
-| 409 | Edit lease conflict or stale revision |
-| 422 | Validation error or operation failed |
+Replace `BASE_URL` with the CoPlan instance URL (e.g. `https://coplan.example.com`).
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 963f909..2538e3e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -8,7 +8,7 @@ class ApplicationController < ActionController::Base
before_action :authenticate_user!
helper CoPlan::ApplicationHelper
- helper_method :current_user, :signed_in?
+ helper_method :current_user, :signed_in?, :show_api_tokens?
private
@@ -20,6 +20,10 @@ def signed_in?
current_user.present?
end
+ def show_api_tokens?
+ CoPlan.configuration.show_api_tokens?
+ end
+
def authenticate_user!
unless signed_in?
redirect_to main_app.sign_in_path, alert: "Please sign in."
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 92ac883..defdd5b 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -26,18 +26,15 @@
CoPlan
<% end %>
-
- <% if signed_in? %>
- <%= link_to "Plans", coplan.plans_path %>
- <%= link_to "Settings", coplan.settings_tokens_path %>
- <% end %>
- <%# TODO: Phase 8.2 — Notifications bell %>
-
-
<% if signed_in? %>
-
-
<%= current_user.name %>
- <%= link_to "Sign out", sign_out_path, data: { turbo_method: :delete } %>
+
+ <%= link_to coplan.settings_root_path, class: "site-nav__icon-link", aria: { label: "Settings" } do %>
+
+ <% end %>
+
+ <%= current_user.name %>
+ <%= link_to "Sign out", sign_out_path, data: { turbo_method: :delete } %>
+
<% end %>
diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css
index 2059f89..a3d8793 100644
--- a/engine/app/assets/stylesheets/coplan/application.css
+++ b/engine/app/assets/stylesheets/coplan/application.css
@@ -414,6 +414,22 @@ img, svg {
resize: vertical;
}
+/* Onboarding banner */
+.onboarding-banner {
+ background: var(--color-primary-light);
+ border-bottom: 1px solid var(--color-primary);
+ padding: var(--space-sm) var(--space-lg);
+ font-size: var(--text-sm);
+ color: var(--color-text);
+ text-align: center;
+}
+
+.onboarding-banner a {
+ color: var(--color-primary);
+ font-weight: 600;
+ text-decoration: underline;
+}
+
/* Empty states */
.empty-state {
text-align: center;
@@ -1388,6 +1404,24 @@ body:has(.comment-toolbar) .main-content {
/* Inbox dropdown */
.inbox-dropdown {
position: relative;
+ display: inline-flex;
+}
+
+.site-nav__icon-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-muted);
+ padding: var(--space-xs);
+ border-radius: var(--radius);
+ line-height: normal;
+ transition: color 0.15s, background 0.15s;
+}
+
+.site-nav__icon-link:hover {
+ color: var(--color-text);
+ background: var(--color-bg);
+ text-decoration: none;
}
.site-nav__bell {
diff --git a/engine/app/controllers/coplan/agent_instructions_controller.rb b/engine/app/controllers/coplan/agent_instructions_controller.rb
new file mode 100644
index 0000000..cc4cb1e
--- /dev/null
+++ b/engine/app/controllers/coplan/agent_instructions_controller.rb
@@ -0,0 +1,11 @@
+module CoPlan
+ class AgentInstructionsController < ApplicationController
+ skip_before_action :authenticate_coplan_user!
+
+ def show
+ @auth_instructions = CoPlan.configuration.agent_auth_instructions
+ @curl = CoPlan.configuration.agent_curl_prefix
+ render layout: false, content_type: "text/markdown", formats: [:text]
+ end
+ end
+end
diff --git a/engine/app/controllers/coplan/api/v1/base_controller.rb b/engine/app/controllers/coplan/api/v1/base_controller.rb
index f2ab554..4542c36 100644
--- a/engine/app/controllers/coplan/api/v1/base_controller.rb
+++ b/engine/app/controllers/coplan/api/v1/base_controller.rb
@@ -3,9 +3,14 @@ module Api
module V1
class BaseController < ActionController::API
before_action :authenticate_api!
+ after_action :set_agent_instructions_header
private
+ def set_agent_instructions_header
+ response.headers["X-Agent-Instructions"] = CoPlan::Engine.routes.url_helpers.agent_instructions_path
+ end
+
def authenticate_api!
token = request.headers["Authorization"]&.delete_prefix("Bearer ")
if token.present?
diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb
index 0d9929b..c368c1b 100644
--- a/engine/app/controllers/coplan/application_controller.rb
+++ b/engine/app/controllers/coplan/application_controller.rb
@@ -16,8 +16,9 @@ def self.controller_path
before_action :authenticate_coplan_user!
before_action :set_coplan_current
+ after_action :set_agent_instructions_header
- helper_method :current_user, :signed_in?
+ helper_method :current_user, :signed_in?, :show_api_tokens?
class NotAuthorizedError < StandardError; end
@@ -43,7 +44,9 @@ def authenticate_coplan_user!
attrs = callback.call(request)
unless attrs && attrs[:external_id].present?
- if CoPlan.configuration.sign_in_path
+ if agent_request?
+ render plain: agent_redirect_instructions, content_type: "text/markdown", status: :unauthorized
+ elsif CoPlan.configuration.sign_in_path
redirect_to CoPlan.configuration.sign_in_path, alert: "Please sign in."
else
head :unauthorized
@@ -67,6 +70,14 @@ def set_coplan_current
CoPlan::Current.user = current_user
end
+ def show_api_tokens?
+ CoPlan.configuration.show_api_tokens?
+ end
+
+ def require_api_tokens_enabled
+ head :not_found unless show_api_tokens?
+ end
+
def authorize!(record, action)
policy_class = "CoPlan::#{record.class.name.demodulize}Policy".constantize
policy = policy_class.new(current_user, record)
@@ -74,5 +85,29 @@ def authorize!(record, action)
raise NotAuthorizedError
end
end
+
+ def set_agent_instructions_header
+ response.headers["X-Agent-Instructions"] = coplan.agent_instructions_path
+ end
+
+ def agent_request?
+ ua = request.user_agent.to_s
+ ua.present? && !ua.start_with?("Mozilla")
+ end
+
+ def agent_redirect_instructions
+ base = request.base_url
+ <<~MARKDOWN
+ # CoPlan API
+
+ You're accessing CoPlan's web UI, which requires browser authentication.
+
+ To interact with CoPlan programmatically, use the API. Full instructions are at:
+
+ #{base}#{coplan.agent_instructions_path}
+
+ Read that document for authentication setup, endpoint reference, and usage examples.
+ MARKDOWN
+ end
end
end
diff --git a/engine/app/controllers/coplan/llms_controller.rb b/engine/app/controllers/coplan/llms_controller.rb
new file mode 100644
index 0000000..7496cf1
--- /dev/null
+++ b/engine/app/controllers/coplan/llms_controller.rb
@@ -0,0 +1,9 @@
+module CoPlan
+ class LlmsController < ApplicationController
+ skip_before_action :authenticate_coplan_user!
+
+ def show
+ render layout: false, content_type: "text/markdown", formats: [:text]
+ end
+ end
+end
diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb
index fdb3082..9586838 100644
--- a/engine/app/controllers/coplan/plans_controller.rb
+++ b/engine/app/controllers/coplan/plans_controller.rb
@@ -11,6 +11,9 @@ def index
.where(plan_id: @plans.select(:id))
.group(:plan_id)
.count
+
+ @show_onboarding_banner = CoPlan.configuration.onboarding_banner.present? &&
+ !current_user.created_plans.exists?
end
def show
diff --git a/engine/app/controllers/coplan/settings/settings_controller.rb b/engine/app/controllers/coplan/settings/settings_controller.rb
new file mode 100644
index 0000000..7c0e84b
--- /dev/null
+++ b/engine/app/controllers/coplan/settings/settings_controller.rb
@@ -0,0 +1,9 @@
+module CoPlan
+ module Settings
+ class SettingsController < ApplicationController
+ def index
+ @api_tokens = current_user.api_tokens.order(created_at: :desc) if show_api_tokens?
+ end
+ end
+ end
+end
diff --git a/engine/app/controllers/coplan/settings/tokens_controller.rb b/engine/app/controllers/coplan/settings/tokens_controller.rb
index 0c36d14..b08e67f 100644
--- a/engine/app/controllers/coplan/settings/tokens_controller.rb
+++ b/engine/app/controllers/coplan/settings/tokens_controller.rb
@@ -1,6 +1,8 @@
module CoPlan
module Settings
class TokensController < ApplicationController
+ before_action :require_api_tokens_enabled, only: [:create, :destroy]
+
def index
@api_tokens = current_user.api_tokens.order(created_at: :desc)
end
diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb
index 0a15b65..0ee945d 100644
--- a/engine/app/models/coplan/user.rb
+++ b/engine/app/models/coplan/user.rb
@@ -1,6 +1,7 @@
module CoPlan
class User < ApplicationRecord
has_many :api_tokens, dependent: :destroy
+ has_many :created_plans, class_name: "CoPlan::Plan", foreign_key: :created_by_user_id, dependent: :nullify, inverse_of: :created_by_user
has_many :plan_collaborators, dependent: :destroy
has_many :notifications, dependent: :destroy
diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb
new file mode 100644
index 0000000..d603dfd
--- /dev/null
+++ b/engine/app/views/coplan/agent_instructions/show.text.erb
@@ -0,0 +1,258 @@
+# CoPlan API
+
+Interact with the CoPlan app to create, read, edit, and comment on plan documents.
+
+<%= @auth_instructions %>
+
+## API Reference
+
+### List Plans
+
+```bash
+<%= @curl %> \
+ "BASE_URL/api/v1/plans" | jq .
+```
+
+Optional query param: `?status=considering`
+
+### Get Plan
+
+```bash
+<%= @curl %> \
+ "BASE_URL/api/v1/plans/$PLAN_ID" | jq .
+```
+
+Returns: `id`, `title`, `status`, `current_content` (markdown), `current_revision`.
+
+### Create Plan
+
+```bash
+<%= @curl %> -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"title": "My Plan", "content": "# My Plan\n\nContent here."}' \
+ "BASE_URL/api/v1/plans" | jq .
+```
+
+### Update Plan
+
+Update plan metadata (title, status, tags). Only fields included in the request body are changed.
+
+```bash
+<%= @curl %> -X PATCH \
+ -H "Content-Type: application/json" \
+ -d '{"status": "considering"}' \
+ "BASE_URL/api/v1/plans/$PLAN_ID" | jq .
+```
+
+Allowed fields: `title` (string), `status` (string), `tags` (array of strings).
+
+### Plan Statuses
+
+Plans move through a lifecycle. **Keep the status current** — update it as the plan progresses.
+
+| Status | Meaning | When to use |
+|--------|---------|-------------|
+| `brainstorm` | Private draft, only visible to the author | Initial creation, not ready for anyone else to see |
+| `considering` | Published, open for review and feedback | Ready for others to read and comment on |
+| `developing` | Actively being implemented | Work has started based on the plan |
+| `live` | Shipped, in production | Implementation is complete |
+| `abandoned` | No longer pursuing | Plan was dropped or superseded |
+
+When you create a plan, it starts as `brainstorm`. Promote it to `considering` when it's ready for review. Move to `developing` when implementation begins. Don't leave plans in stale states.
+
+### Get Versions
+
+```bash
+<%= @curl %> \
+ "BASE_URL/api/v1/plans/$PLAN_ID/versions" | jq .
+```
+
+### Get Comments
+
+```bash
+<%= @curl %> \
+ "BASE_URL/api/v1/plans/$PLAN_ID/comments" | jq .
+```
+
+## Editing Plans (Lease + Operations)
+
+Editing requires three steps: acquire lease → apply operations → release lease.
+
+### 1. Acquire Edit Lease
+
+```bash
+LEASE_RESPONSE=$(<%= @curl %> -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"lease_token": "'$(openssl rand -hex 32)'"}' \
+ "BASE_URL/api/v1/plans/$PLAN_ID/lease")
+echo "$LEASE_RESPONSE" | jq .
+LEASE_TOKEN=$(echo "$LEASE_RESPONSE" | jq -r '.lease_token')
+```
+
+Leases expire after 5 minutes. Renew with PATCH, release with DELETE.
+
+### 2. Apply Operations
+
+```bash
+<%= @curl %> -X POST \
+ -H "Content-Type: application/json" \
+ -d '{
+ "lease_token": "'$LEASE_TOKEN'",
+ "base_revision": 1,
+ "change_summary": "Updated goals section",
+ "operations": [
+ {
+ "op": "replace_exact",
+ "old_text": "old text here",
+ "new_text": "new text here",
+ "count": 1
+ }
+ ]
+ }' \
+ "BASE_URL/api/v1/plans/$PLAN_ID/operations" | jq .
+```
+
+**Important:** Set `base_revision` to the plan's `current_revision`. Returns 409 if stale.
+
+### 3. Release Edit Lease
+
+```bash
+<%= @curl %> -X DELETE \
+ -H "Content-Type: application/json" \
+ -d '{"lease_token": "'$LEASE_TOKEN'"}' \
+ "BASE_URL/api/v1/plans/$PLAN_ID/lease"
+```
+
+### Renew Lease (if needed for long edits)
+
+```bash
+<%= @curl %> -X PATCH \
+ -H "Content-Type: application/json" \
+ -d '{"lease_token": "'$LEASE_TOKEN'"}' \
+ "BASE_URL/api/v1/plans/$PLAN_ID/lease" | jq .
+```
+
+## Available Operations
+
+### replace_exact
+
+Find and replace exact text. Fails if text not found or count exceeded.
+
+```json
+{
+ "op": "replace_exact",
+ "old_text": "We should use MySQL",
+ "new_text": "We should use PostgreSQL",
+ "count": 1
+}
+```
+
+### insert_under_heading
+
+Insert content after a markdown heading. Fails if heading not found or ambiguous.
+
+```json
+{
+ "op": "insert_under_heading",
+ "heading": "## Testing Strategy",
+ "content": "- Add integration tests\n- Mock external providers"
+}
+```
+
+### delete_paragraph_containing
+
+Delete the paragraph containing a needle string. Fails if 0 or >1 paragraphs match.
+
+```json
+{
+ "op": "delete_paragraph_containing",
+ "needle": "This approach is deprecated"
+}
+```
+
+## Commenting
+
+### Create Comment Thread
+
+```bash
+<%= @curl %> -X POST \
+ -H "Content-Type: application/json" \
+ -d '{
+ "body_markdown": "This section needs more detail.",
+ "anchor_text": "the exact text you are commenting on",
+ "agent_name": "Amp"
+ }' \
+ "BASE_URL/api/v1/plans/$PLAN_ID/comments" | jq .
+```
+
+- `anchor_text`: the exact text from the plan content that this comment is about. It will be highlighted in the UI and the comment will be anchored to it.
+- Omit `anchor_text` for a general (non-anchored) comment.
+- `agent_name`: optional identifier for the agent (e.g., `"Amp"`, `"Claude"`). Displayed in the UI as "User Name (Agent Name)".
+
+### Reply to Thread
+
+```bash
+<%= @curl %> -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"body_markdown": "Good point, I will address this.", "agent_name": "Amp"}' \
+ "BASE_URL/api/v1/plans/$PLAN_ID/comments/$THREAD_ID/reply" | jq .
+```
+
+### Resolve Thread
+
+Mark a comment thread as resolved (addressed by the plan author or thread creator).
+
+```bash
+<%= @curl %> -X PATCH \
+ "BASE_URL/api/v1/plans/$PLAN_ID/comments/$THREAD_ID/resolve" | jq .
+```
+
+### Dismiss Thread
+
+Dismiss a comment thread (plan author only — for comments that are out of scope or not applicable).
+
+```bash
+<%= @curl %> -X PATCH \
+ "BASE_URL/api/v1/plans/$PLAN_ID/comments/$THREAD_ID/dismiss" | jq .
+```
+
+## Reviewing a Plan
+
+When asked to review a plan (given a plan URL or ID), follow this workflow:
+
+### 1. Read the Plan and Comments
+
+```bash
+<%= @curl %> "BASE_URL/api/v1/plans/$PLAN_ID" | jq .
+<%= @curl %> "BASE_URL/api/v1/plans/$PLAN_ID/comments" | jq .
+```
+
+### 2. Triage Comments
+
+Review each open comment thread and categorize it:
+
+- **Just do it** — Clear, actionable feedback that can be applied directly. Apply these edits without asking.
+- **Confirm first** — Substantive changes where the right fix is clear but the scope is large. Summarize and ask before applying.
+- **Discuss** — Ambiguous feedback or design decisions. Present to the user for discussion.
+
+### 3. Apply Edits
+
+For approved changes: acquire lease → apply operations → release lease → resolve threads.
+
+## Typical Workflow
+
+1. **Read** the plan: `GET /api/v1/plans/:id`
+2. **Acquire lease**: `POST /api/v1/plans/:id/lease`
+3. **Apply operations**: `POST /api/v1/plans/:id/operations` (can call multiple times while lease is held)
+4. **Release lease**: `DELETE /api/v1/plans/:id/lease`
+5. **Comment** on changes: `POST /api/v1/plans/:id/comments`
+
+## Error Codes
+
+| Code | Meaning |
+|------|---------|
+| 401 | Not authenticated |
+| 403 | Not authorized for this action |
+| 404 | Plan not found (or no access) |
+| 409 | Edit lease conflict or stale revision |
+| 422 | Validation error or operation failed |
diff --git a/engine/app/views/coplan/llms/show.text.erb b/engine/app/views/coplan/llms/show.text.erb
new file mode 100644
index 0000000..ee5f1c5
--- /dev/null
+++ b/engine/app/views/coplan/llms/show.text.erb
@@ -0,0 +1,9 @@
+# CoPlan
+
+> CoPlan is a collaborative engineering design doc platform. Humans write plans, domain experts leave inline feedback, and AI agents respond to that feedback and apply edits automatically. Every change is versioned with full provenance.
+
+CoPlan has a REST API that AI agents can use to create plans, apply edits, and leave review comments.
+
+## API
+
+- [Agent Instructions](<%= coplan.agent_instructions_path %>): Full API reference for AI agents, including authentication, endpoints, editing workflow, and commenting.
diff --git a/engine/app/views/coplan/settings/settings/index.html.erb b/engine/app/views/coplan/settings/settings/index.html.erb
new file mode 100644
index 0000000..127d6ff
--- /dev/null
+++ b/engine/app/views/coplan/settings/settings/index.html.erb
@@ -0,0 +1,7 @@
+
+
+<% if show_api_tokens? %>
+ <%= render "coplan/settings/tokens/tokens", api_tokens: @api_tokens %>
+<% end %>
diff --git a/engine/app/views/coplan/settings/tokens/_tokens.html.erb b/engine/app/views/coplan/settings/tokens/_tokens.html.erb
new file mode 100644
index 0000000..593efba
--- /dev/null
+++ b/engine/app/views/coplan/settings/tokens/_tokens.html.erb
@@ -0,0 +1,30 @@
+API Tokens
+Manage API tokens for agent access
+
+
+ <% if flash[:raw_token].present? %>
+ <%= render "coplan/settings/tokens/token_reveal", raw_token: flash[:raw_token] %>
+ <% end %>
+
+
+
+ <%= render "coplan/settings/tokens/form" %>
+
+
+
+
Your Tokens
+
+
+
+ Name
+ Status
+ Last Used
+ Created
+
+
+
+
+ <%= render partial: "coplan/settings/tokens/token_row", collection: api_tokens, as: :token %>
+
+
+
diff --git a/engine/app/views/coplan/settings/tokens/index.html.erb b/engine/app/views/coplan/settings/tokens/index.html.erb
index 6f3db1f..0a2ff5b 100644
--- a/engine/app/views/coplan/settings/tokens/index.html.erb
+++ b/engine/app/views/coplan/settings/tokens/index.html.erb
@@ -1,32 +1,35 @@
-
- <% if flash[:raw_token].present? %>
- <%= render "token_reveal", raw_token: flash[:raw_token] %>
- <% end %>
-
+<% if show_api_tokens? %>
+ Manage API tokens for agent access
-
- <%= render "form" %>
-
+
+ <% if flash[:raw_token].present? %>
+ <%= render "token_reveal", raw_token: flash[:raw_token] %>
+ <% end %>
+
-
-
Your Tokens
-
-
-
- Name
- Status
- Last Used
- Created
-
-
-
-
- <%= render partial: "token_row", collection: @api_tokens, as: :token %>
-
-
-
+
+ <%= render "form" %>
+
+
+
+
Your Tokens
+
+
+
+ Name
+ Status
+ Last Used
+ Created
+
+
+
+
+ <%= render partial: "token_row", collection: @api_tokens, as: :token %>
+
+
+
+<% end %>
diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb
index 19e3b52..ac128ec 100644
--- a/engine/app/views/layouts/coplan/application.html.erb
+++ b/engine/app/views/layouts/coplan/application.html.erb
@@ -21,14 +21,11 @@
CoPlan
<%= coplan_environment_badge %>
<% end %>
-
- <% if signed_in? %>
- <%= link_to "Plans", coplan.plans_path %>
- <%= link_to "Settings", coplan.settings_tokens_path %>
- <% end %>
-
<% if signed_in? %>
+ <%= link_to coplan.settings_root_path, class: "site-nav__icon-link", aria: { label: "Settings" } do %>
+
+ <% end %>
<%= turbo_stream_from "coplan_notifications:#{current_user.id}" %>
@@ -52,6 +49,9 @@
<% end %>
+ <% if @show_onboarding_banner %>
+
<%= CoPlan.configuration.onboarding_banner.html_safe %>
+ <% end %>
<% if notice.present? %>
<%= notice %>
diff --git a/engine/config/routes.rb b/engine/config/routes.rb
index 0eb616b..032bebb 100644
--- a/engine/config/routes.rb
+++ b/engine/config/routes.rb
@@ -15,6 +15,7 @@
end
namespace :settings do
+ root "settings#index"
resources :tokens, only: [:index, :create, :destroy]
end
@@ -46,5 +47,8 @@
end
end
+ get "llms.txt", to: "llms#show", as: :llms_txt
+ get "agent-instructions", to: "agent_instructions#show", as: :agent_instructions
+
root "plans#index"
end
diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb
index 97bcfcc..a52801a 100644
--- a/engine/lib/coplan/configuration.rb
+++ b/engine/lib/coplan/configuration.rb
@@ -4,6 +4,9 @@ class Configuration
attr_accessor :ai_base_url, :ai_api_key, :ai_model
attr_accessor :error_reporter
attr_accessor :notification_handler
+ attr_accessor :onboarding_banner
+ attr_accessor :agent_auth_instructions
+ attr_accessor :agent_curl_prefix
def initialize
@authenticate = nil
@@ -12,6 +15,34 @@ def initialize
@ai_model = "gpt-4o"
@error_reporter = ->(exception, context) { Rails.error.report(exception, context: context) }
@notification_handler = nil
+ @onboarding_banner = 'Want to upload Agentic plans? Give your agent these instructions .'
+ @agent_curl_prefix = 'curl -s -H "Authorization: Bearer $TOKEN"'
+ @agent_auth_instructions = <<~MARKDOWN
+ ## Authentication
+
+ Credentials are stored at `~/.config/coplan/credentials.json`:
+
+ ```json
+ {
+ "base_url": "BASE_URL",
+ "token": "your-token-here"
+ }
+ ```
+
+ On first use:
+
+ 1. Read `~/.config/coplan/credentials.json` to get `token` and `base_url`.
+ 2. If the file does not exist, tell the user: "Go to **Settings → API Tokens** in the CoPlan web UI to create a token." Ask for the token and base URL, then save to `~/.config/coplan/credentials.json` with `chmod 600`.
+ 3. If any API call returns 401, the token is invalid or revoked. Prompt the user to create a new token in Settings and update the credentials file.
+
+ Use the values from the credentials file in all API calls below.
+
+ All requests use `Authorization: Bearer $TOKEN` header.
+ MARKDOWN
+ end
+
+ def show_api_tokens?
+ @api_authenticate.nil?
end
end
end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 2706b70..e873bbd 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -23,6 +23,8 @@
+
+
def sign_in_as(coplan_user)
coplan_user.update!(email: "#{coplan_user.external_id}@test.example.com") unless coplan_user.email.present?
post sign_in_path, params: { email: coplan_user.email }
diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb
index 0b642dd..ae778bc 100644
--- a/spec/requests/plans_spec.rb
+++ b/spec/requests/plans_spec.rb
@@ -109,4 +109,36 @@
get plan_path(brainstorm_plan)
expect(response).to have_http_status(:ok)
end
+
+ describe "onboarding banner" do
+ it "shows banner when user has no plans" do
+ sign_in_as(bob)
+ get plans_path
+ expect(response.body).to include("onboarding-banner")
+ end
+
+ it "hides banner when user has created a plan" do
+ plan # alice has a plan
+ get plans_path
+ expect(response.body).not_to include("onboarding-banner")
+ end
+
+ it "hides banner when onboarding_banner config is nil" do
+ sign_in_as(bob)
+ original = CoPlan.configuration.onboarding_banner
+ CoPlan.configuration.onboarding_banner = nil
+ get plans_path
+ expect(response.body).not_to include("onboarding-banner")
+ CoPlan.configuration.onboarding_banner = original
+ end
+
+ it "displays custom banner text from configuration" do
+ sign_in_as(bob)
+ original = CoPlan.configuration.onboarding_banner
+ CoPlan.configuration.onboarding_banner = "Custom onboarding message"
+ get plans_path
+ expect(response.body).to include("Custom onboarding message")
+ CoPlan.configuration.onboarding_banner = original
+ end
+ end
end