This commit is contained in:
debu-2 2026-10-02 22:39:04 +05:30
parent d272ae0bd3
commit ff01efe4bb
17 changed files with 1378 additions and 21 deletions

View file

@ -22,6 +22,7 @@ class SendAgentMessagePayload(BaseModel):
template: str = Field(min_length=2, max_length=64)
to: str = Field(min_length=10, max_length=20)
customer_name: str = Field(min_length=1, max_length=120)
report_group: str = Field(min_length=3, max_length=50)
report_id: str | None = Field(default=None, max_length=120)
@ -72,6 +73,18 @@ def require_subscription(db: Session, user: UserAccount, template: AgentMessageT
return subscription
def _validate_report_group(report_group: str) -> str:
cleaned = report_group.strip().lower()
if not cleaned or len(cleaned) > 50:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid report_group")
if not re.fullmatch(r"[a-z][a-z0-9-]{1,48}[a-z0-9]", cleaned) or "--" in cleaned:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="report_group must be 3-50 chars: lowercase letters, numbers, hyphens",
)
return cleaned
def _validate_report_id(report_id: str) -> str:
cleaned = report_id.strip()
if not cleaned or len(cleaned) > 120:
@ -84,7 +97,12 @@ def _validate_report_id(report_id: str) -> str:
return cleaned
def _build_template_components(template: AgentMessageTemplate, customer_name: str, report_id: str) -> list[dict]:
def _build_template_components(
template: AgentMessageTemplate,
customer_name: str,
report_group: str,
report_id: str,
) -> list[dict]:
components: list[dict] = [
{
"type": "body",
@ -103,7 +121,7 @@ def _build_template_components(template: AgentMessageTemplate, customer_name: st
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{"type": "text", "text": report_id}],
"parameters": [{"type": "text", "text": f"{report_group}/{report_id}"}],
}
)
return components
@ -128,8 +146,9 @@ async def send_agent_template_message(
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
report_group = _validate_report_group(payload.report_group)
report_id = _validate_report_id(payload.report_id or f"run-{secrets.token_hex(3)}")
components = _build_template_components(template, payload.customer_name, report_id)
components = _build_template_components(template, payload.customer_name, report_group, report_id)
client = _whatsapp_client(db)
try:
@ -149,6 +168,7 @@ async def send_agent_template_message(
recipient=recipient,
parameters={
"customer_name": payload.customer_name.strip(),
"report_group": report_group,
"report_id": report_id,
},
wa_message_id=wa_message_id,
@ -159,12 +179,17 @@ async def send_agent_template_message(
db.commit()
db.refresh(log)
report_url = f"{template.button_url_base.rstrip('/')}/{report_id}" if template.button_url_base else None
report_url = (
f"{template.button_url_base.rstrip('/')}/{report_group}/{report_id}"
if template.button_url_base
else None
)
return {
"success": True,
"template": template.slug,
"to": recipient,
"customer_name": payload.customer_name.strip(),
"report_group": report_group,
"report_id": report_id,
"report_url": report_url,
"wa_message_id": wa_message_id,
@ -201,6 +226,7 @@ async def run_console_template_test(
*,
to: str,
customer_name: str,
report_group: str,
report_id: str | None = None,
) -> dict:
require_subscription(db, user, template)
@ -215,6 +241,7 @@ async def run_console_template_test(
template=template.slug,
to=to,
customer_name=customer_name,
report_group=report_group,
report_id=report_id,
)
result = await send_agent_template_message(db, user, payload, source="console_test")
@ -250,12 +277,14 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
"template": f"Template slug. Use `{template.slug}`.",
"to": "Recipient WhatsApp number with country code, e.g. 919149154337",
"customer_name": "Recipient name inserted into the template body",
"report_id": "Optional unique report id for the View Report button URL suffix",
"report_group": "Urmiport report group slug used in the hosted report URL",
"report_id": "Optional report postfix; auto-generated when omitted",
},
"example": {
"template": template.slug,
"to": "919149154337",
"customer_name": "Suyash",
"report_group": "acme-agent-runs",
"report_id": "run-abc123",
},
"response_example": {
@ -263,8 +292,9 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
"template": template.slug,
"to": "919149154337",
"customer_name": "Suyash",
"report_group": "acme-agent-runs",
"report_id": "run-abc123",
"report_url": f"{template.button_url_base or ''}run-abc123",
"report_url": f"{template.button_url_base or ''}acme-agent-runs/run-abc123",
"wa_message_id": "wamid.HBg...",
"dispatch_id": "00000000-0000-0000-0000-000000000001",
},
@ -272,6 +302,7 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
"notes": [
"Subscribe to the template in the Urmi console before calling this API.",
"Messages are sent from the Urmi AI WhatsApp business number on your behalf.",
"If report_id is omitted, Urmi generates a unique id automatically.",
"Create the report group in Console → Agent → Urmiport before sending.",
"If report_id is omitted, Urmi generates a unique postfix automatically.",
],
}

View file

@ -28,11 +28,18 @@ DEFAULT_AGENT_TEMPLATES = [
"description": "Recipient display name shown in the greeting",
"example": "Suyash",
},
{
"name": "report_group",
"type": "string",
"required": True,
"description": "Urmiport report group slug used in the hosted report URL",
"example": "acme-agent-runs",
},
{
"name": "report_id",
"type": "string",
"required": True,
"description": "Unique report identifier appended to the View Report button URL",
"description": "Report postfix appended after the report group in the View Report button URL",
"example": "run-abc123",
},
],

View file

@ -28,6 +28,8 @@ from .routes_voice import router as voice_router
from .routes_whatsapp import router as whatsapp_router
from .routes_agent_templates import router as agent_templates_router
from .routes_agent_messages import router as agent_messages_router
from .routes_urmiport import router as urmiport_router
from .routes_urmiport_api import router as urmiport_api_router
from .agent_templates_seed import seed_agent_message_templates
@ -95,6 +97,8 @@ app.include_router(voice_router)
app.include_router(whatsapp_router)
app.include_router(agent_templates_router)
app.include_router(agent_messages_router)
app.include_router(urmiport_router)
app.include_router(urmiport_api_router)
app.include_router(admin_router)

View file

@ -109,6 +109,12 @@ class UserAccount(Base):
agent_message_dispatches: Mapped[list["AgentMessageDispatchLog"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
urmiport_report_groups: Mapped[list["UrmiportReportGroup"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
urmiport_reports: Mapped[list["UrmiportReport"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class PasswordCredential(Base):
@ -609,6 +615,49 @@ class AgentMessageDispatchLog(Base):
template: Mapped[AgentMessageTemplate] = relationship()
class UrmiportReportGroup(Base):
__tablename__ = "urmiport_report_groups"
__table_args__ = (UniqueConstraint("user_id", "slug", name="uq_urmiport_group_user_slug"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
slug: Mapped[str] = mapped_column(String(50), index=True)
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
default_access: Mapped[str] = mapped_column(String(16), default="public", index=True)
password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True)
id_mode: Mapped[str] = mapped_column(String(16), default="auto")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[UserAccount] = relationship(back_populates="urmiport_report_groups")
reports: Mapped[list["UrmiportReport"]] = relationship(
back_populates="group", cascade="all, delete-orphan"
)
class UrmiportReport(Base):
__tablename__ = "urmiport_reports"
__table_args__ = (UniqueConstraint("group_id", "report_id", name="uq_urmiport_group_report_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
group_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("urmiport_report_groups.id", ondelete="CASCADE"), index=True
)
report_id: Mapped[str] = mapped_column(String(120), index=True)
html_body: Mapped[str] = mapped_column(Text)
access_mode: Mapped[str] = mapped_column(String(16), default="public", index=True)
password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped[UserAccount] = relationship(back_populates="urmiport_reports")
group: Mapped[UrmiportReportGroup] = relationship(back_populates="reports")
class PlatformSetting(Base):
"""Runtime overrides for platform credentials (admin-editable)."""

View file

@ -45,10 +45,16 @@ def _explorer_doc_for_template(template: AgentMessageTemplate) -> dict:
"description": "Recipient name shown in the template body",
"example": "Suyash",
},
"report_group": {
"type": "string",
"required": True,
"description": "Urmiport report group slug used in the hosted report URL",
"example": "acme-agent-runs",
},
"report_id": {
"type": "string",
"required": False,
"description": "Report id suffix for the dynamic View Report button URL",
"description": "Report postfix; auto-generated when omitted",
"example": "run-abc123",
},
},
@ -60,8 +66,9 @@ def _explorer_doc_for_template(template: AgentMessageTemplate) -> dict:
"template": template.slug,
"to": "919149154337",
"customer_name": "Suyash",
"report_group": "acme-agent-runs",
"report_id": "run-abc123",
"report_url": f"{template.button_url_base or ''}run-abc123",
"report_url": f"{template.button_url_base or ''}acme-agent-runs/run-abc123",
"wa_message_id": "wamid.HBg...",
"dispatch_id": "00000000-0000-0000-0000-000000000001",
},

View file

@ -20,6 +20,7 @@ router = APIRouter(prefix="/api/v1/agent-templates", tags=["agent-templates"])
class AgentTemplateTestRequest(BaseModel):
to: str = Field(min_length=10, max_length=20)
customer_name: str = Field(min_length=1, max_length=120)
report_group: str = Field(min_length=3, max_length=50)
report_id: str | None = Field(default=None, max_length=120)
@ -140,5 +141,6 @@ async def test_agent_template(
template,
to=payload.to,
customer_name=payload.customer_name,
report_group=payload.report_group,
report_id=payload.report_id,
)

View file

@ -0,0 +1,180 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .auth import current_user
from .database import get_db
from .models import UrmiportReport, UrmiportReportGroup, UserAccount
from .urmiport_service import (
CreateUrmiportGroupPayload,
UpdateUrmiportGroupPayload,
build_urmiport_api_docs,
get_group_by_slug,
get_report,
get_user_group,
hash_password,
normalize_group_slug,
serialize_group,
serialize_report,
verify_password,
)
router = APIRouter(prefix="/api/v1/urmiport", tags=["urmiport"])
class UnlockReportRequest(BaseModel):
password: str = Field(min_length=1, max_length=120)
@router.get("/docs")
def urmiport_docs() -> dict:
return build_urmiport_api_docs()
@router.get("/groups")
def list_report_groups(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
groups = db.scalars(
select(UrmiportReportGroup)
.where(UrmiportReportGroup.user_id == user.id)
.order_by(UrmiportReportGroup.created_at.desc())
).all()
counts = dict(
db.execute(
select(UrmiportReport.group_id, func.count(UrmiportReport.id))
.where(UrmiportReport.user_id == user.id)
.group_by(UrmiportReport.group_id)
).all()
)
return [serialize_group(group, counts.get(group.id, 0)) for group in groups]
@router.post("/groups")
def create_report_group(
payload: CreateUrmiportGroupPayload,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
try:
slug = normalize_group_slug(payload.report_group)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
existing = db.scalar(
select(UrmiportReportGroup).where(
UrmiportReportGroup.user_id == user.id,
UrmiportReportGroup.slug == slug,
)
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Report group already exists")
if payload.default_access == "password" and not payload.password:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="password is required")
group = UrmiportReportGroup(
user_id=user.id,
slug=slug,
display_name=payload.display_name or slug,
default_access=payload.default_access,
password_hash=hash_password(payload.password) if payload.default_access == "password" else None,
id_mode=payload.id_mode,
)
db.add(group)
db.commit()
db.refresh(group)
return serialize_group(group, 0)
@router.patch("/groups/{slug}")
def update_report_group(
slug: str,
payload: UpdateUrmiportGroupPayload,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
group = get_user_group(db, user, slug)
if payload.display_name is not None:
group.display_name = payload.display_name.strip() or group.slug
if payload.default_access is not None:
group.default_access = payload.default_access
if payload.default_access == "password":
if payload.password:
group.password_hash = hash_password(payload.password)
elif not group.password_hash:
raise HTTPException(status_code=422, detail="password is required")
else:
group.password_hash = None
elif payload.password:
group.password_hash = hash_password(payload.password)
if payload.id_mode is not None:
group.id_mode = payload.id_mode
db.commit()
db.refresh(group)
count = db.scalar(
select(func.count(UrmiportReport.id)).where(UrmiportReport.group_id == group.id)
) or 0
return serialize_group(group, count)
@router.delete("/groups/{slug}")
def delete_report_group(
slug: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
group = get_user_group(db, user, slug)
db.delete(group)
db.commit()
return {"success": True}
@router.get("/groups/{slug}/reports")
def list_group_reports(
slug: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
group = get_user_group(db, user, slug)
reports = db.scalars(
select(UrmiportReport)
.where(UrmiportReport.group_id == group.id)
.order_by(UrmiportReport.updated_at.desc())
).all()
return [serialize_report(report, group) for report in reports]
@router.get("/view/{group_slug}/{report_id}")
def view_report_metadata(
group_slug: str,
report_id: str,
db: Session = Depends(get_db),
) -> dict:
group = get_group_by_slug(db, group_slug)
report = get_report(db, group, report_id)
return {
"report_group": group.slug,
"report_id": report.report_id,
"access": report.access_mode,
"requires_password": report.access_mode == "password",
"html": report.html_body if report.access_mode == "public" else None,
}
@router.post("/view/{group_slug}/{report_id}/unlock")
def unlock_report(
group_slug: str,
report_id: str,
payload: UnlockReportRequest,
db: Session = Depends(get_db),
) -> dict:
group = get_group_by_slug(db, group_slug)
report = get_report(db, group, report_id)
if report.access_mode != "password":
return {"html": report.html_body}
if not verify_password(payload.password, report.password_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid password")
return {"html": report.html_body}

View file

@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .api_key_auth import current_api_user
from .database import get_db
from .models import UserAccount
from .urmiport_service import UpsertUrmiportReportPayload, build_urmiport_api_docs, upsert_report_for_user
router = APIRouter(prefix="/api/v1/urmiport", tags=["urmiport-api"])
@router.get("/reports/docs")
def urmiport_reports_api_docs() -> dict:
return build_urmiport_api_docs()
@router.post("/reports")
def publish_report(
payload: UpsertUrmiportReportPayload,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_api_user),
) -> dict:
return upsert_report_for_user(db, user, payload)

View file

@ -0,0 +1,300 @@
import re
import secrets
from datetime import datetime, timezone
from fastapi import HTTPException, status
from pydantic import BaseModel, Field
from pwdlib import PasswordHash
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import UrmiportReport, UrmiportReportGroup, UserAccount
password_hasher = PasswordHash.recommended()
GROUP_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{1,48}[a-z0-9]$")
REPORT_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$")
class UpsertUrmiportReportPayload(BaseModel):
report_group: str = Field(min_length=3, max_length=50)
report_id: str | None = Field(default=None, max_length=120)
body: str = Field(min_length=1)
access: str = Field(default="public", pattern="^(public|password)$")
password: str | None = Field(default=None, max_length=120)
class CreateUrmiportGroupPayload(BaseModel):
report_group: str = Field(min_length=3, max_length=50)
display_name: str | None = Field(default=None, max_length=120)
default_access: str = Field(default="public", pattern="^(public|password)$")
password: str | None = Field(default=None, max_length=120)
id_mode: str = Field(default="auto", pattern="^(auto|manual)$")
class UpdateUrmiportGroupPayload(BaseModel):
display_name: str | None = Field(default=None, max_length=120)
default_access: str | None = Field(default=None, pattern="^(public|password)$")
password: str | None = Field(default=None, max_length=120)
id_mode: str | None = Field(default=None, pattern="^(auto|manual)$")
def normalize_group_slug(value: str) -> str:
slug = value.strip().lower()
if not GROUP_SLUG_RE.match(slug) or "--" in slug:
raise ValueError("Report group must be 3-50 chars: lowercase letters, numbers, hyphens")
return slug
def normalize_report_id(value: str) -> str:
report_id = value.strip()
if not REPORT_ID_RE.match(report_id):
raise ValueError("Report id may contain letters, numbers, dots, underscores, and hyphens")
return report_id
def hash_password(value: str | None) -> str | None:
if not value:
return None
return password_hasher.hash(value)
def verify_password(value: str, hashed: str | None) -> bool:
if not hashed:
return False
return password_hasher.verify(value, hashed)
def report_public_url(group_slug: str, report_id: str) -> str:
return f"https://urmiai.com/reports/{group_slug}/{report_id}"
def serialize_group(group: UrmiportReportGroup, report_count: int = 0) -> dict:
return {
"id": str(group.id),
"report_group": group.slug,
"display_name": group.display_name,
"default_access": group.default_access,
"password_protected": group.default_access == "password",
"id_mode": group.id_mode,
"report_count": report_count,
"url_pattern": f"https://urmiai.com/reports/{group.slug}/{{report_id}}",
"created_at": group.created_at,
}
def serialize_report(report: UrmiportReport, group: UrmiportReportGroup) -> dict:
return {
"id": str(report.id),
"report_group": group.slug,
"report_id": report.report_id,
"access": report.access_mode,
"password_protected": report.access_mode == "password",
"url": report_public_url(group.slug, report.report_id),
"updated_at": report.updated_at,
"created_at": report.created_at,
}
def get_user_group(db: Session, user: UserAccount, slug: str) -> UrmiportReportGroup:
normalized = normalize_group_slug(slug)
group = db.scalar(
select(UrmiportReportGroup).where(
UrmiportReportGroup.user_id == user.id,
UrmiportReportGroup.slug == normalized,
)
)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report group not found")
return group
def get_group_by_slug(db: Session, slug: str) -> UrmiportReportGroup:
try:
normalized = normalize_group_slug(slug)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report group not found") from exc
group = db.scalar(select(UrmiportReportGroup).where(UrmiportReportGroup.slug == normalized))
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report group not found")
return group
def get_report(db: Session, group: UrmiportReportGroup, report_id: str) -> UrmiportReport:
try:
normalized = normalize_report_id(report_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found") from exc
report = db.scalar(
select(UrmiportReport).where(
UrmiportReport.group_id == group.id,
UrmiportReport.report_id == normalized,
)
)
if not report:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found")
return report
def generate_report_id(db: Session, group: UrmiportReportGroup) -> str:
for _ in range(12):
candidate = f"run-{secrets.token_hex(3)}"
exists = db.scalar(
select(UrmiportReport.id).where(
UrmiportReport.group_id == group.id,
UrmiportReport.report_id == candidate,
)
)
if not exists:
return candidate
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unable to generate report id")
def upsert_report_for_user(
db: Session,
user: UserAccount,
payload: UpsertUrmiportReportPayload,
) -> dict:
try:
group_slug = normalize_group_slug(payload.report_group)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
group = db.scalar(
select(UrmiportReportGroup).where(
UrmiportReportGroup.user_id == user.id,
UrmiportReportGroup.slug == group_slug,
)
)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report group not found. Create it in Console → Agent → Urmiport first.",
)
if payload.report_id:
try:
report_id = normalize_report_id(payload.report_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
elif group.id_mode == "manual":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="report_id is required for this report group",
)
else:
report_id = generate_report_id(db, group)
access_mode = payload.access
password_hash_value = (
hash_password(payload.password)
if access_mode == "password"
else None
)
if access_mode == "password" and not password_hash_value and not group.password_hash:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="password is required when access is password",
)
if access_mode == "password" and not password_hash_value:
password_hash_value = group.password_hash
existing = db.scalar(
select(UrmiportReport).where(
UrmiportReport.group_id == group.id,
UrmiportReport.report_id == report_id,
)
)
if existing:
existing.html_body = payload.body
existing.access_mode = access_mode
existing.password_hash = password_hash_value
existing.updated_at = datetime.now(timezone.utc)
report = existing
else:
report = UrmiportReport(
user_id=user.id,
group_id=group.id,
report_id=report_id,
html_body=payload.body,
access_mode=access_mode,
password_hash=password_hash_value,
)
db.add(report)
db.commit()
db.refresh(report)
result = serialize_report(report, group)
result["success"] = True
return result
def build_urmiport_api_docs() -> dict:
return {
"id": "urmiport-upsert-report",
"method": "POST",
"path": "/api/v1/urmiport/reports",
"title": "Publish Urmiport HTML report",
"summary": (
"Create or update a hosted HTML report. Reports are served at "
"https://urmiai.com/reports/{report_group}/{report_id} without Urmi branding."
),
"authentication": {
"type": "api_key",
"header": "Authorization",
"format": "Bearer urmi_live_<key>",
"alternate_header": "X-Api-Key",
},
"request_body": {
"report_group": {
"type": "string",
"required": True,
"description": "Report group slug created in Console → Agent → Urmiport",
"example": "acme-agent-runs",
},
"report_id": {
"type": "string",
"required": False,
"description": "Report postfix. Required when group id_mode is manual; auto-generated when omitted for auto groups",
"example": "run-abc123",
},
"body": {
"type": "string",
"required": True,
"description": "Full HTML document or HTML fragment to render on the public report URL",
"example": "<html><body><h1>Agent run complete</h1><p>All checks passed.</p></body></html>",
},
"access": {
"type": "string",
"required": False,
"description": "public or password",
"example": "public",
},
"password": {
"type": "string",
"required": False,
"description": "Required when access is password (unless group default password is set)",
"example": "secret-pass",
},
},
"responses": {
"200": {
"description": "Report created or updated",
"example": {
"success": True,
"report_group": "acme-agent-runs",
"report_id": "run-abc123",
"url": "https://urmiai.com/reports/acme-agent-runs/run-abc123",
"access": "public",
},
},
"401": {"description": "Missing or invalid API key"},
"404": {"description": "Report group not found"},
"422": {"description": "Validation error"},
},
"notes": [
"Create the report group in the Urmi console before calling this API.",
"Upsert semantics: same report_group + report_id updates the existing HTML body.",
"The hosted page renders only your HTML — no Urmi chrome or branding.",
],
}