Projects API
Docsfy treats every generated docs set as a variant, not a single flat "project" row. The Projects API is how you list accessible variants, start new generations, inspect one exact variant, abort work in progress, delete stored output, download archives, and serve the rendered docs site.
A variant is identified by five values: name, branch, ai_provider, ai_model, and owner.
CREATE TABLE IF NOT EXISTS projects (
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
ai_provider TEXT NOT NULL DEFAULT '',
ai_model TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '',
repo_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'generating',
current_stage TEXT,
last_commit_sha TEXT,
last_generated TEXT,
page_count INTEGER DEFAULT 0,
error_message TEXT,
plan_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (name, branch, ai_provider, ai_model, owner)
)
owner is part of the stored identity, but it is not part of the main URL path. That is why exact variant routes sometimes need ?owner=<username> for admin disambiguation.
Note: docsfy does not publish Swagger or an OpenAPI JSON document. The routes below are the API reference.
Quick Route Map
| What you want to do | Method and path |
|---|---|
| List all accessible variants | GET /api/status |
| Same as above | GET /api/projects |
| List all accessible variants for one project name | GET /api/projects/{name} |
| Get one exact variant | GET /api/projects/{name}/{branch}/{provider}/{model} |
| Start a generation | POST /api/generate |
| Abort a project when only one active variant matches | POST /api/projects/{name}/abort |
| Abort one exact variant | POST /api/projects/{name}/{branch}/{provider}/{model}/abort |
| Delete one exact variant | DELETE /api/projects/{name}/{branch}/{provider}/{model} |
| Delete all variants for one owner-scoped project | DELETE /api/projects/{name} |
Download one exact variant as tar.gz |
GET /api/projects/{name}/{branch}/{provider}/{model}/download |
| Download the latest ready variant | GET /api/projects/{name}/download |
| Serve one exact rendered site file | GET /docs/{project}/{branch}/{provider}/{model}/{path:path} |
| Serve a file from the latest ready rendered site | GET /docs/{project}/{path:path} |
These two list routes are aliases of the same handler:
@router.get("/status")
@router.get("/projects")
async def status(request: Request) -> dict[str, Any]:
return await build_projects_payload(request.state.username, request.state.is_admin)
@router.get("/projects/{name}")
async def get_project_details(request: Request, name: str) -> dict[str, Any]:
Authentication, Roles, and Owner Scoping
All /api/* and /docs/* routes require authentication. In practice, you usually use one of these:
- A Bearer token for scripts and CLI usage
- A
docsfy_sessioncookie for browser usage
If you use the CLI, the checked-in example config looks like this:
[default]
server = "dev"
[servers.dev]
url = "http://localhost:8000"
username = "admin"
password = "<your-dev-key>"
[servers.prod]
url = "https://docsfy.example.com"
username = "admin"
password = "<your-prod-key>"
[servers.staging]
url = "https://staging.docsfy.example.com"
username = "deployer"
password = "<your-staging-key>"
With a profile like this, the CLI commands docsfy generate, docsfy status, docsfy abort, docsfy delete, and docsfy download call the same HTTP routes documented here.
Role behavior is important:
| Role | Can list / look up / view / download? | Can generate? | Can abort or delete? | Owner behavior |
|---|---|---|---|---|
viewer |
Yes, for accessible variants | No | No | ?owner= does not grant extra access |
user |
Yes, for owned variants and explicitly shared variants | Yes, for their own variants | Yes, for their own active runs and owned variants | ?owner= is ignored on exact lookup routes |
admin |
Yes, across all owners | Yes | Yes | Use ?owner=<username> on exact variant routes when needed |
Shared access is granted separately under the admin API. Once a project is shared, a non-admin user can list it, open its docs, and download it, but they still cannot delete another owner's data or abort another owner's active run. See User and Access Management for the sharing endpoints.
Access grants are owner-scoped, not global to a repository name:
CREATE TABLE IF NOT EXISTS project_access (
project_name TEXT NOT NULL,
project_owner TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL,
PRIMARY KEY (project_name, project_owner, username)
)
Note: For browser requests to
/docs/*, unauthenticated HTML requests are redirected to/login. API-style requests get401 Unauthorized.Warning: For exact variant routes,
?owner=is an admin disambiguation tool, not a general-purpose access override. If you are not an admin, docsfy resolves the variant from your own account and your granted access only.
Branches, Providers, and Defaults
Branch is part of the variant identity and part of the URL. main and dev are different variants even when everything else is identical.
Docsfy validates the generation request body like this:
class GenerateRequest(BaseModel):
repo_url: str | None = Field(
default=None, description="Git repository URL (HTTPS or SSH)"
)
repo_path: str | None = Field(default=None, description="Local git repository path")
ai_provider: Literal["claude", "gemini", "cursor"] | None = None
ai_model: str | None = None
ai_cli_timeout: int | None = Field(default=None, gt=0)
force: bool = Field(
default=False, description="Force full regeneration, ignoring cache"
)
branch: str = Field(
default=DEFAULT_BRANCH, description="Git branch to generate docs from"
)
@field_validator("branch")
@classmethod
def validate_branch(cls, v: str) -> str:
if "/" in v:
msg = (
f"Invalid branch name: '{v}'. Branch names cannot contain slashes "
"— use hyphens instead (e.g., release-1.x)."
)
raise ValueError(msg)
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$", v):
msg = f"Invalid branch name: '{v}'"
raise ValueError(msg)
if ".." in v:
msg = f"Invalid branch name: '{v}'"
raise ValueError(msg)
return v
@model_validator(mode="after")
def validate_source(self) -> GenerateRequest:
if not self.repo_url and not self.repo_path:
msg = "Either 'repo_url' or 'repo_path' must be provided"
raise ValueError(msg)
if self.repo_url and self.repo_path:
msg = "Provide either 'repo_url' or 'repo_path', not both"
raise ValueError(msg)
return self
In practice:
- Omit
branchand docsfy usesmain - Good branch names include
main,dev,release-1.x, andv2.0.1 - Slash-based branch names like
release/v2.0are rejected repo_urlandrepo_pathare mutually exclusiverepo_pathis for admin-only local generation workflows
The server also has real defaults for provider, model, timeout, and data directory:
admin_key: str = "" # Required — validated at startup
ai_provider: str = "cursor"
ai_model: str = "gpt-5.4-xhigh-fast"
ai_cli_timeout: int = Field(default=60, gt=0)
log_level: str = "INFO"
data_dir: str = "/data"
secure_cookies: bool = True # Set to False for local HTTP dev
Warning: The branch lives in the URL path, so it must be a single safe path segment. If your Git workflow uses slash-based branch names, use a hyphenated variant such as
release-1.xwhen generating docs with docsfy.
List Projects and Variants
Use GET /api/status or GET /api/projects when you want the dashboard-style view of everything you can access.
The response shape used by the frontend is:
export interface Project {
name: string
branch: string
ai_provider: string
ai_model: string
owner: string
repo_url: string
status: ProjectStatus
current_stage: string | null
last_commit_sha: string | null
last_generated: string | null
page_count: number
error_message: string | null
plan_json: string | null
created_at: string
updated_at: string
}
export interface ProjectsResponse {
projects: Project[]
known_models: Record<string, string[]>
known_branches: Record<string, string[]>
}
This endpoint is useful for polling because it returns:
projects: a flat list of accessible variantsknown_models: model suggestions grouped by providerknown_branches: branch suggestions grouped by project name
A few details matter:
projectsincludes every accessible variant, not justreadyonesknown_modelsandknown_branchesare built from successfulreadyvariants, so they behave like suggestions rather than a full history- Non-admin users see owned variants plus explicitly shared variants
- Admins see all owners' variants
If you want all visible variants for one repository name, use GET /api/projects/{name}. That returns:
namevariants: every accessible variant for that project name
If you want one exact variant, use GET /api/projects/{name}/{branch}/{provider}/{model}.
Tip: Use
/api/statuswhen you are building a dashboard, poller, or "watch until ready" script. Use/api/projects/{name}/{branch}/{provider}/{model}when you need one exact row and you already know the coordinates.
Status and Stage Fields
status is one of:
generatingreadyerroraborted
While generation is running, current_stage can move through these UI-visible stages:
export const GENERATION_STAGES = [
'cloning',
'planning',
'incremental_planning',
'generating_pages',
'validating',
'cross_linking',
'rendering',
] as const
A ready variant may also keep current_stage = up_to_date when docsfy determines that the current output already matches the target commit and no regeneration is needed.
Start a Generation
POST /api/generate queues a generation and returns immediately with 202 Accepted. The work happens asynchronously.
The frontend sends this request body when a user clicks Generate:
await api.post('/api/generate', {
repo_url: submittedRepoUrl,
branch: submittedBranch,
ai_provider: submittedProvider,
ai_model: submittedModel,
force: submittedForce,
})
A real API test-plan example also shows that omitting branch falls back to main:
curl -s -X POST http://localhost:8800/api/generate -H "Authorization: Bearer <TEST_USER_PASSWORD>" -H "Content-Type: application/json" -d '{"repo_url":"https://github.com/myk-org/for-testing-only","ai_provider":"gemini","ai_model":"gemini-2.5-flash"}'
The stored project name is derived automatically from the repository URL or local directory name:
@property
def project_name(self) -> str:
if self.repo_url:
return extract_repo_name(self.repo_url)
if self.repo_path:
return Path(self.repo_path).resolve().name
return "unknown"
Important generation rules:
- Send either
repo_urlorrepo_path, never both repo_urlis the normal user-facing path and should be a standard Git HTTPS or SSH URLrepo_pathmust be an absolute local path, must exist, must contain.git, and is admin-only- If you omit
ai_providerorai_model, docsfy uses the server defaults - Starting the exact same owner/branch/provider/model variant twice at the same time returns
409
Warning:
repo_urlis validated and basic SSRF protections are enforced. Localhost, bare local paths, and private-network repository targets are rejected. If you need a local checkout, use adminrepo_pathinstead.
Abort Running Work
Docsfy exposes two abort routes:
POST /api/projects/{name}/abortPOST /api/projects/{name}/{branch}/{provider}/{model}/abort
Use the project-name route only as a convenience. It succeeds only when there is exactly one active generation for that project name. If multiple active variants match the name, docsfy returns 409 and tells you to use the branch-specific route.
The exact variant abort URL used by the UI is:
await api.post(
`/api/projects/${project.name}/${project.branch}/${project.ai_provider}/${project.ai_model}/abort?owner=${encodeURIComponent(project.owner)}`
)
Practical rules:
viewercannot abort anythingusercan abort only their own active generationadmincan abort any active variant, and?owner=helps disambiguate when multiple owners have the same exact coordinates- If cancellation is still in progress, the abort route can return
409and ask you to retry shortly
Tip: In automation, prefer the exact variant abort route. The project-name abort route is best reserved for human convenience when you know only one generation is active.
Delete Stored Output
Docsfy also exposes two delete shapes:
DELETE /api/projects/{name}/{branch}/{provider}/{model}deletes one exact variantDELETE /api/projects/{name}deletes all variants for one owner-scoped project group
The UI's exact variant delete call includes ?owner=:
await api.delete(
`/api/projects/${project.name}/${project.branch}/${project.ai_provider}/${project.ai_model}?owner=${encodeURIComponent(project.owner)}`
)
The dashboard's "delete all variants" behavior also owner-scopes each delete call:
// Collect distinct owners for this project name so each delete call
// includes the required ?owner= query parameter.
const owners = [...new Set(
projects
.filter((p) => p.name === name && (!ownerFilter || p.owner === ownerFilter))
.map((p) => p.owner)
)]
for (const owner of owners) {
await api.delete(`/api/projects/${name}?owner=${encodeURIComponent(owner)}`)
}
Deletion rules:
viewercannot deleteusercan delete only their own variantsadminmust provide?owner=<username>for delete routes so docsfy knows which owner-scoped project to remove- If a matching variant is still generating, delete returns
409; abort it first
Warning:
DELETE /api/projects/{name}is not "delete this repo globally." It deletes all variants for one owner-scoped project group. For admins,?owner=is required.
Download Archives
There are two download routes:
GET /api/projects/{name}/{branch}/{provider}/{model}/downloadGET /api/projects/{name}/download
Use the exact variant route when you need a stable artifact. Use the short route only when "latest ready" is acceptable.
A real end-to-end example downloads an exact variant archive and extracts it:
curl -s -L -H "Authorization: Bearer $ADMIN_KEY" \
"$SERVER/api/projects/for-testing-only/main/$BASELINE_PROVIDER/$BASELINE_MODEL/download" \
-o "$CROSS_PROVIDER_ROOT/baseline.tar.gz"
mkdir -p "$CROSS_PROVIDER_ROOT/baseline"
tar -xzf "$CROSS_PROVIDER_ROOT/baseline.tar.gz" --strip-components=1 -C "$CROSS_PROVIDER_ROOT/baseline"
ls "$CROSS_PROVIDER_ROOT/baseline"
What to expect:
- Downloads work only for
readyvariants - The response is
application/gzip - Exact variant downloads are named
<project>-<branch>-<provider>-<model>-docs.tar.gz - Latest-route downloads are named
<project>-docs.tar.gz - The archive contains a top-level directory, so
tar --strip-components=1is useful when extracting into an existing folder
Tip: Use the exact variant download route in release pipelines, QA jobs, and bug reports. It is pinned to one build and does not change when a newer variant becomes ready later.
Serve Rendered Docs
Docsfy serves the generated static site directly under /docs.
The exact variant route is:
@app.get("/docs/{project}/{branch}/{provider}/{model}/{path:path}")
async def serve_variant_docs(
request: Request,
project: str,
branch: str,
provider: str,
model: str,
path: str = "index.html",
) -> FileResponse:
The short "latest ready" route is:
@app.get("/docs/{project}/{path:path}")
async def serve_docs(
request: Request, project: str, path: str = "index.html"
) -> FileResponse:
"""Serve the most recently generated variant."""
if not path or path == "/":
path = "index.html"
The dashboard builds exact docs and download links like this:
const docsUrl = `/docs/${project.name}/${project.branch}/${project.ai_provider}/${project.ai_model}/?owner=${encodeURIComponent(project.owner)}`
const downloadUrl = `/api/projects/${project.name}/${project.branch}/${project.ai_provider}/${project.ai_model}/download?owner=${encodeURIComponent(project.owner)}`
Use the exact docs route when you want one pinned build:
/docs/<project>/<branch>/<provider>/<model>//docs/<project>/<branch>/<provider>/<model>/index.html/docs/<project>/<branch>/<provider>/<model>/introduction.html
Use the short docs route when you want "whatever is newest and ready":
/docs/<project>//docs/<project>/index.html
A few behaviors are easy to miss:
- Empty docs paths resolve to
index.html - Any generated file under the rendered site can be served through the same prefix
- The short
/docs/<project>/...route picks the newest accessiblereadyvariant bylast_generated, not by branch name or commit SHA - For admins, "latest" is global across all owners for that project name
- For non-admin users, "latest" means the newest owned or explicitly shared
readyvariant they can access - For non-admin users, the short route can also return
409if two accessible owners tie for the newest ready timestamp - If you need one specific owner, branch, provider, or model, use the exact route instead of the short route
Warning: The short docs and download routes are convenience URLs. They can point to a different build after the next successful generation, and they do not let you choose an owner explicitly.
Note: Exact variant docs routes can use
?owner=<username>for admin disambiguation. The short "latest" docs route does not let you select an owner.
Common Response Codes
| Code | When you will see it |
|---|---|
200 OK |
Successful list, lookup, delete, abort, download, or docs-file response |
202 Accepted |
POST /api/generate accepted the job and queued async work |
400 Bad Request |
Invalid delete usage, invalid repo path, invalid download state such as "variant not ready," or missing required owner on admin delete |
401 Unauthorized |
Missing or invalid authentication for API-style requests |
403 Forbidden |
Viewer tried a write route, non-admin tried repo_path, or a docs file path tried to escape the rendered site |
404 Not Found |
The project, variant, site file, or active generation does not exist or is not accessible to you |
409 Conflict |
Duplicate generation, ambiguous owner selection, multiple active variants for a name-based abort, delete while generating, or cancellation still finishing |
422 Unprocessable Entity |
Request-body validation failed, such as an invalid repo URL or invalid branch name |
Practical Recommendations
- Use
GET /api/statusfor dashboards and progress polling. - Use exact variant routes in scripts, release tooling, and bookmarks you want to stay stable.
- Keep
?owner=when the UI gives it to you on exact variant routes as an admin. - Treat the short
/docs/<project>/...and/api/projects/<project>/downloadroutes as convenience shortcuts, not permanent identifiers. - If you are sharing docs across users, remember that sharing affects what a user can read, not what they can delete or abort.