commit 16d7a5fc4ffc2f55fd08f3538905cabb75612d45 Author: a2a-platform Date: Wed May 27 15:53:43 2026 +0000 deploy diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eb2dce0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__ +*.pyc +.venv +.git +.pytest_cache +node_modules +.a2a +.env +.env.local diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..75d6056 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,38 @@ +name: build +on: + push: + branches: [main] + paths-ignore: + - 'deploy/**' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: build image + run: | + IMG=registry.a2acloud.io/agents/blog-openapi-agent + docker build --pull -t "$IMG:$GITHUB_SHA" -t "$IMG:latest" . + docker push "$IMG:$GITHUB_SHA" + docker push "$IMG:latest" + + - name: bump deploy manifest + run: | + IMG=registry.a2acloud.io/agents/blog-openapi-agent + sed -i "s|image: $IMG:.*|image: $IMG:$GITHUB_SHA|" deploy/20-deployment.yaml + sed -i "s|value: $IMG:.*|value: $IMG:$GITHUB_SHA|" deploy/20-deployment.yaml + git config user.email "ci@a2a.local" + git config user.name "ci" + git add deploy/20-deployment.yaml + if git diff --staged --quiet; then + echo "no manifest changes" + else + git commit -m "ci: bump image to $GITHUB_SHA" + git push origin HEAD:main + fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e7e9e8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM registry.a2acloud.io/a2a/a2a-pack-base:latest + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + + +ENV A2A_ENTRYPOINT=agent:BlogOpenapiAgent +ENV PORT=8000 +EXPOSE 8000 + +CMD a2a run --entrypoint "$A2A_ENTRYPOINT" --host 0.0.0.0 --port 8000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6229a5 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# blog-openapi-agent + +Generated A2APack agent for a2a cloud blog API. + +- Source OpenAPI: https://blog.a2acloud.io/openapi.json +- Generated operations: 6 +- Main skill: `auto` + +The generated code is intentionally editable. Tune prompts, operation +grouping, auth names, and safety policy before publishing serious agents. diff --git a/a2a.yaml b/a2a.yaml new file mode 100644 index 0000000..a94aa1d --- /dev/null +++ b/a2a.yaml @@ -0,0 +1,14 @@ +name: blog-openapi-agent +version: 0.1.0 +entrypoint: agent:BlogOpenapiAgent +description: Publishing and read API for the a2a cloud blog. Published content is + public; draft, preview, create, update, and delete operations require the blog API + key. +runtime: + resources: + cpu: 200m + memory: 512Mi + egress: + allow_hosts: + - blog.a2acloud.io + deny_internet_by_default: true diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..a7529b2 --- /dev/null +++ b/agent.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import base64 +import json +from typing import Any + +import httpx +from deepagents import create_deep_agent +from langchain_core.messages import BaseMessage +from langchain_core.tools import StructuredTool +from langchain_openai import ChatOpenAI +from pydantic import BaseModel, Field + +from a2a_pack import ( + A2AAgent, + ConsumerSetup, + ConsumerSetupField, + ConsumerSetupMissing, + EgressPolicy, + LLMProvisioning, + Pricing, + Resources, + RunContext, + skill, +) + + +DEFAULT_BASE_URL = "https://blog.a2acloud.io" +OPERATIONS = json.loads("{\n \"create_blog_post\": {\n \"description\": \"Create a blog post\",\n \"destructive\": true,\n \"method\": \"POST\",\n \"operation_id\": \"create_blog_post\",\n \"parameters\": [],\n \"path\": \"/api/posts\",\n \"request_body\": {\n \"$ref\": \"#/components/schemas/BlogPostInput\"\n },\n \"security\": [\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"create_blog_post\",\n \"summary\": \"Create a blog post\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"delete_blog_post\": {\n \"description\": \"Delete a post by slug\",\n \"destructive\": true,\n \"method\": \"DELETE\",\n \"operation_id\": \"delete_blog_post\",\n \"parameters\": [],\n \"path\": \"/api/posts/{slug}\",\n \"request_body\": null,\n \"security\": [\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"delete_blog_post\",\n \"summary\": \"Delete a post by slug\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"get_blog_health\": {\n \"description\": \"Check blog service health\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"get_blog_health\",\n \"parameters\": [],\n \"path\": \"/api/healthz\",\n \"request_body\": null,\n \"security\": null,\n \"skill_name\": \"get_blog_health\",\n \"summary\": \"Check blog service health\",\n \"tags\": [\n \"Health\"\n ]\n },\n \"get_blog_post\": {\n \"description\": \"Returns a published post. Add `preview=1` to fetch drafts or future-dated posts; preview requests require either bearer auth or the `x-api-key` header.\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"get_blog_post\",\n \"parameters\": [\n {\n \"description\": \"Set to `1` to bypass the public published-post filter. Requires authentication.\",\n \"in\": \"query\",\n \"name\": \"preview\",\n \"required\": false,\n \"schema\": {\n \"enum\": [\n \"1\"\n ],\n \"type\": \"string\"\n }\n }\n ],\n \"path\": \"/api/posts/{slug}\",\n \"request_body\": null,\n \"security\": [\n {},\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"get_blog_post\",\n \"summary\": \"Get a post by slug\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"list_blog_posts\": {\n \"description\": \"Returns published posts by default. `status=all` and `status=draft` include draft content and require either bearer auth or the `x-api-key` header.\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"list_blog_posts\",\n \"parameters\": [\n {\n \"description\": \"Use `published` or omit the parameter for public published posts. `all` and `draft` require authentication.\",\n \"in\": \"query\",\n \"name\": \"status\",\n \"required\": false,\n \"schema\": {\n \"default\": \"published\",\n \"enum\": [\n \"published\",\n \"draft\",\n \"all\"\n ],\n \"type\": \"string\"\n }\n }\n ],\n \"path\": \"/api/posts\",\n \"request_body\": null,\n \"security\": [\n {},\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"list_blog_posts\",\n \"summary\": \"List posts\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"upsert_blog_post\": {\n \"description\": \"Upsert a post by slug\",\n \"destructive\": true,\n \"method\": \"PUT\",\n \"operation_id\": \"upsert_blog_post\",\n \"parameters\": [],\n \"path\": \"/api/posts/{slug}\",\n \"request_body\": {\n \"$ref\": \"#/components/schemas/BlogPostInput\"\n },\n \"security\": [\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"upsert_blog_post\",\n \"summary\": \"Upsert a post by slug\",\n \"tags\": [\n \"Posts\"\n ]\n }\n}") +ROOT_SECURITY = json.loads("[]") +SECURITY_SCHEMES = json.loads("{\n \"apiKeyAuth\": {\n \"description\": \"Use the configured `BLOG_API_KEY` value.\",\n \"in\": \"header\",\n \"name\": \"x-api-key\",\n \"type\": \"apiKey\"\n },\n \"bearerAuth\": {\n \"description\": \"Use `Authorization: Bearer $BLOG_API_KEY`.\",\n \"scheme\": \"bearer\",\n \"type\": \"http\"\n }\n}") +SECURITY_FIELDS = json.loads("{\n \"apiKeyAuth\": {\n \"field\": \"X_API_KEY\",\n \"kind\": \"apiKey\",\n \"location\": \"header\",\n \"name\": \"x-api-key\"\n },\n \"bearerAuth\": {\n \"field\": \"BEARERAUTH_TOKEN\",\n \"kind\": \"http\",\n \"scheme\": \"bearer\"\n }\n}") + + +class OperationInput(BaseModel): + parameters: dict[str, Any] = Field( + default_factory=dict, + description="Path, query, header, and cookie parameters keyed by OpenAPI parameter name.", + ) + body: Any | None = Field(default=None, description="JSON request body, when the operation accepts one.") + + +class BlogOpenapiAgent(A2AAgent): + name = "blog-openapi-agent" + description = "Publishing and read API for the a2a cloud blog. Published content is public; draft, preview, create, update, and delete operations require the blog API key." + version = "0.1.0" + consumer_setup = ConsumerSetup.from_fields( + ConsumerSetupField.config("OPENAPI_BASE_URL", label="API base URL", description="Override the default API server (https://blog.a2acloud.io).", required=False, input_type="url"), + ConsumerSetupField.secret("BEARERAUTH_TOKEN", label="bearerAuth bearer credential", description="Use `Authorization: Bearer $BLOG_API_KEY`.", required=False), + ConsumerSetupField.secret("X_API_KEY", label="apiKeyAuth API key", description="Use the configured `BLOG_API_KEY` value.", required=False), + ) + llm_provisioning = LLMProvisioning.PLATFORM + pricing = Pricing( + price_per_call_usd=0.0, + caller_pays_llm=False, + notes="Uses a scoped platform LLM grant through ctx.llm.", + ) + resources = Resources(cpu="200m", memory="512Mi") + egress = EgressPolicy( + allow_hosts=('blog.a2acloud.io',), + deny_internet_by_default=True, + ) + tools_used = ("openapi", "deepagents") + capabilities = { + "openapi_auto_agent": { + "operation_count": len(OPERATIONS), + "default_base_url": DEFAULT_BASE_URL, + "security_schemes": list(SECURITY_SCHEMES), + } + } + + @skill( + name="auto", + description="Use the OpenAPI service to complete a natural-language goal.", + tags=("openapi", "auto"), + timeout_seconds=180, + ) + async def auto(self, ctx: RunContext, goal: str) -> dict[str, Any]: + creds = ctx.llm + if not creds.api_key: + return { + "error": "llm_credentials_missing", + "final": ( + "LLM credentials were not available for this run. Hosted " + "generated OpenAPI agents should receive a platform LLM grant." + ), + "messages": [], + } + model_kwargs: dict[str, Any] = { + "model": creds.model, + "base_url": creds.base_url, + "api_key": creds.api_key, + } + if creds.temperature_mode != "omit" and creds.temperature is not None: + model_kwargs["temperature"] = creds.temperature + if creds.extra_body: + model_kwargs["extra_body"] = dict(creds.extra_body) + model = ChatOpenAI(**model_kwargs) + graph = create_deep_agent( + model=model, + tools=self._operation_tools(ctx), + system_prompt=self._system_prompt(), + ) + result = await graph.ainvoke({"messages": [{"role": "user", "content": goal}]}) + messages = result.get("messages", []) if isinstance(result, dict) else [] + final = _message_text(messages[-1]) if messages else result + return { + "final": final, + "messages": [_message_to_dict(message) for message in messages[-8:]], + } + + @skill( + name="get_blog_health", + description="GET /api/healthz - Check blog service health", + tags=('Health',), + timeout_seconds=60, + ) + async def get_blog_health( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "get_blog_health", + parameters=parameters or {}, + body=body, + ) + + @skill( + name="list_blog_posts", + description="GET /api/posts - List posts", + tags=('Posts',), + timeout_seconds=60, + ) + async def list_blog_posts( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "list_blog_posts", + parameters=parameters or {}, + body=body, + ) + + @skill( + name="create_blog_post", + description="POST /api/posts - Create a blog post", + tags=('Posts',), + timeout_seconds=120, + ) + async def create_blog_post( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "create_blog_post", + parameters=parameters or {}, + body=body, + ) + + @skill( + name="get_blog_post", + description="GET /api/posts/{slug} - Get a post by slug", + tags=('Posts',), + timeout_seconds=60, + ) + async def get_blog_post( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "get_blog_post", + parameters=parameters or {}, + body=body, + ) + + @skill( + name="upsert_blog_post", + description="PUT /api/posts/{slug} - Upsert a post by slug", + tags=('Posts',), + timeout_seconds=120, + ) + async def upsert_blog_post( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "upsert_blog_post", + parameters=parameters or {}, + body=body, + ) + + @skill( + name="delete_blog_post", + description="DELETE /api/posts/{slug} - Delete a post by slug", + tags=('Posts',), + timeout_seconds=120, + ) + async def delete_blog_post( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "delete_blog_post", + parameters=parameters or {}, + body=body, + ) + + def _operation_tools(self, ctx: RunContext) -> list[StructuredTool]: + tools: list[StructuredTool] = [] + for operation_id, operation in OPERATIONS.items(): + async def call( + parameters: dict[str, Any] | None = None, + body: Any | None = None, + *, + _operation_id: str = operation_id, + ) -> dict[str, Any]: + return await self._request( + ctx, + _operation_id, + parameters=parameters or {}, + body=body, + ) + + tools.append( + StructuredTool.from_function( + coroutine=call, + name=operation["skill_name"], + description=self._tool_description(operation), + args_schema=OperationInput, + ) + ) + return tools + + def _system_prompt(self) -> str: + lines = [ + "You operate an API through generated OpenAPI tools.", + "Call tools to get real results. Do not invent API responses.", + "For write, update, or delete operations, explain the intended action before calling the tool.", + "If an operation reports missing consumer setup, tell the user which setup field is required.", + "", + "Available operations:", + ] + for operation in OPERATIONS.values(): + marker = "WRITE" if operation.get("destructive") else "READ" + lines.append( + f"- {operation['skill_name']}: {marker} {operation['method']} {operation['path']} — {operation['summary']}" + ) + return "\n".join(lines) + + def _tool_description(self, operation: dict[str, Any]) -> str: + parts = [ + f"{operation['method']} {operation['path']}", + str(operation.get("description") or operation.get("summary") or ""), + ] + if operation.get("parameters"): + parts.append( + "Parameters: " + + ", ".join( + f"{p['name']} in {p['in']}{' required' if p.get('required') else ''}" + for p in operation["parameters"] + ) + ) + if operation.get("request_body") is not None: + parts.append("Accepts a JSON request body.") + return "\n".join(part for part in parts if part) + + async def _request( + self, + ctx: RunContext, + operation_id: str, + *, + parameters: dict[str, Any], + body: Any | None, + ) -> dict[str, Any]: + operation = OPERATIONS[operation_id] + base_url = str(ctx.consumer_config("OPENAPI_BASE_URL", DEFAULT_BASE_URL) or DEFAULT_BASE_URL).rstrip("/") + url, query, headers = self._request_parts(ctx, operation, parameters) + request_kwargs: dict[str, Any] = { + "params": query, + "headers": headers, + } + if body is not None: + request_kwargs["json"] = body + async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: + response = await client.request( + operation["method"], + f"{base_url}{url}", + **request_kwargs, + ) + content_type = response.headers.get("content-type", "") + try: + payload: Any = response.json() if "json" in content_type else response.text + except ValueError: + payload = response.text + if response.status_code >= 400: + return { + "ok": False, + "status_code": response.status_code, + "operation_id": operation_id, + "error": payload, + } + return { + "ok": True, + "status_code": response.status_code, + "operation_id": operation_id, + "result": payload, + } + + def _request_parts( + self, + ctx: RunContext, + operation: dict[str, Any], + parameters: dict[str, Any], + ) -> tuple[str, dict[str, Any], dict[str, str]]: + path = operation["path"] + query: dict[str, Any] = {} + headers: dict[str, str] = {} + cookies: dict[str, Any] = {} + for param in operation.get("parameters") or []: + name = param["name"] + if name not in parameters: + if param.get("required"): + raise ValueError(f"missing required parameter {name!r}") + continue + value = parameters[name] + location = param["in"] + if location == "path": + path = path.replace(" + name + ", str(value)) + elif location == "query": + query[name] = value + elif location == "header": + headers[name] = str(value) + elif location == "cookie": + cookies[name] = value + if cookies: + headers["cookie"] = "; ".join(f"{key}={value}" for key, value in cookies.items()) + auth_headers, auth_query = self._auth_for_operation(ctx, operation) + headers.update(auth_headers) + query.update(auth_query) + return path, query, headers + + def _auth_for_operation(self, ctx: RunContext, operation: dict[str, Any]) -> tuple[dict[str, str], dict[str, Any]]: + requirements = operation.get("security") + if requirements is None: + requirements = ROOT_SECURITY + if requirements == []: + return {}, {} + missing: list[str] = [] + for requirement in requirements or []: + if not isinstance(requirement, dict) or not requirement: + return {}, {} + headers: dict[str, str] = {} + query: dict[str, Any] = {} + ok = True + for scheme_name in requirement: + mapping = SECURITY_FIELDS.get(scheme_name) or {} + secret_name = mapping.get("field") + value = _consumer_secret_optional(ctx, secret_name) if secret_name else "" + if not value: + ok = False + if secret_name: + missing.append(secret_name) + continue + kind = mapping.get("kind") + if kind == "apiKey": + if mapping.get("location") == "query": + query[mapping.get("name") or scheme_name] = value + else: + headers[mapping.get("name") or scheme_name] = value + elif kind in {"http", "oauth2"}: + scheme = mapping.get("scheme") or "Bearer" + if scheme.lower() == "basic": + token = base64.b64encode(value.encode("utf-8")).decode("ascii") + headers["authorization"] = f"Basic {token}" + else: + headers["authorization"] = f"{scheme} {value}" + else: + ok = False + if ok: + return headers, query + if missing: + raise ConsumerSetupMissing( + "operation requires consumer setup secret(s): " + + ", ".join(sorted(set(missing))) + ) + return {}, {} + + +def _consumer_secret_optional(ctx: RunContext, name: str | None) -> str: + if not name: + return "" + try: + return ctx.consumer_secret(name) + except ConsumerSetupMissing: + return "" + + +def _message_text(message: Any) -> Any: + content = getattr(message, "content", message) + return content + + +def _message_to_dict(message: Any) -> dict[str, Any]: + if isinstance(message, BaseMessage): + return message.model_dump(mode="json") + if hasattr(message, "model_dump"): + return message.model_dump(mode="json") + if isinstance(message, dict): + return message + return {"content": str(message)} + + +agent = BlogOpenapiAgent() diff --git a/deploy/20-deployment.yaml b/deploy/20-deployment.yaml new file mode 100644 index 0000000..0390831 --- /dev/null +++ b/deploy/20-deployment.yaml @@ -0,0 +1,102 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: blog-openapi-agent + namespace: agents + labels: + app: blog-openapi-agent + a2a/managed-by: control-plane +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: blog-openapi-agent + template: + metadata: + labels: + app: blog-openapi-agent + spec: + containers: + - name: agent + image: registry.a2acloud.io/agents/blog-openapi-agent:latest + imagePullPolicy: Always + env: + - name: A2A_AGENT_IMAGE + value: registry.a2acloud.io/agents/blog-openapi-agent:latest + - name: A2A_RENDER_IMAGE + value: registry.a2acloud.io/agents/blog-openapi-agent:latest + - name: A2A_CP_URL + value: "http://control-plane.control-plane.svc.cluster.local" + - name: A2A_LOGIN_URL + value: "https://app.a2acloud.io" + - name: A2A_SESSION_COOKIE_NAME + value: "a2a_session" + - name: A2A_GRANT_VERIFYING_KEY + valueFrom: + secretKeyRef: {name: platform-secrets, key: grant_verifying_key} + - name: A2A_SANDBOX_URL + value: http://sandbox.sandbox.svc.cluster.local:8000 + - name: A2A_SANDBOX_TIMEOUT_S + value: "180" + envFrom: + - secretRef: + name: blog-openapi-agent-agent-secrets + optional: true + ports: + - containerPort: 8000 + name: http + readinessProbe: + httpGet: {path: /healthz, port: 8000} + initialDelaySeconds: 2 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + httpGet: {path: /healthz, port: 8000} + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 6 + resources: + requests: {cpu: "200m", memory: "512Mi"} + limits: {cpu: "200m", memory: "512Mi"} +--- +apiVersion: v1 +kind: Service +metadata: + name: blog-openapi-agent + namespace: agents +spec: + type: ClusterIP + selector: + app: blog-openapi-agent + ports: + - name: http + port: 80 + targetPort: 8000 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: blog-openapi-agent + namespace: agents + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure +spec: + tls: + - hosts: [blog-openapi-agent.a2acloud.io] + secretName: blog-openapi-agent-a2acloud-io-tls + rules: + - host: blog-openapi-agent.a2acloud.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: blog-openapi-agent + port: + number: 80 diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..058976e --- /dev/null +++ b/openapi.json @@ -0,0 +1,626 @@ +{ + "components": { + "parameters": { + "Slug": { + "description": "Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "example": "shipping-agents-with-receipts", + "maxLength": 120, + "minLength": 1, + "type": "string" + } + } + }, + "responses": { + "AuthNotConfigured": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "`BLOG_API_KEY` is not configured on the server." + }, + "BadRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The request body, slug, or query parameters are invalid." + }, + "NotFound": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The requested post was not found." + }, + "Unauthorized": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The blog API key is missing or invalid." + } + }, + "schemas": { + "BlogPost": { + "properties": { + "author": { + "example": "a2a cloud", + "type": "string" + }, + "content": { + "example": "# Shipping agents with receipts\n\nAgents need durable runtime boundaries.", + "type": "string" + }, + "cover_image_url": { + "example": "https://blog.a2acloud.io/brand/og-image.png", + "format": "uri", + "maxLength": 500, + "type": [ + "string", + "null" + ] + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "excerpt": { + "example": "Why agent infrastructure needs identity, authority, and proof.", + "maxLength": 420, + "type": "string" + }, + "id": { + "example": 42, + "format": "int64", + "type": "integer" + }, + "published_at": { + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "slug": { + "example": "shipping-agents-with-receipts", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/BlogStatus" + }, + "tags": { + "example": [ + "platform", + "agents" + ], + "items": { + "type": "string" + }, + "maxItems": 12, + "type": "array" + }, + "title": { + "example": "Shipping agents with receipts", + "maxLength": 180, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "slug", + "title", + "excerpt", + "content", + "author", + "status", + "tags", + "cover_image_url", + "created_at", + "updated_at", + "published_at" + ], + "type": "object" + }, + "BlogPostInput": { + "properties": { + "author": { + "default": "a2a cloud", + "maxLength": 120, + "type": "string" + }, + "content": { + "example": "# Shipping agents with receipts\n\nAgents need durable runtime boundaries.", + "maxLength": 120000, + "minLength": 1, + "type": "string" + }, + "cover_image_url": { + "format": "uri", + "maxLength": 500, + "type": "string" + }, + "excerpt": { + "default": "", + "example": "Why agent infrastructure needs identity, authority, and proof.", + "maxLength": 420, + "type": "string" + }, + "published_at": { + "description": "Defaults to the current time for published posts and null for drafts.", + "format": "date-time", + "type": "string" + }, + "slug": { + "description": "Optional on create. The path slug is used for upserts. Slugs are normalized to lowercase kebab-case.", + "example": "shipping-agents-with-receipts", + "maxLength": 120, + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/BlogStatus", + "default": "published" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 12, + "type": "array" + }, + "title": { + "example": "Shipping agents with receipts", + "maxLength": 180, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "title", + "content" + ], + "type": "object" + }, + "BlogStatus": { + "enum": [ + "draft", + "published" + ], + "type": "string" + }, + "DeleteResponse": { + "properties": { + "ok": { + "const": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + "ErrorResponse": { + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + "HealthError": { + "properties": { + "error": { + "type": "string" + }, + "ok": { + "const": false, + "type": "boolean" + } + }, + "required": [ + "ok", + "error" + ], + "type": "object" + }, + "HealthOk": { + "properties": { + "ok": { + "const": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + "PostListResponse": { + "properties": { + "posts": { + "items": { + "$ref": "#/components/schemas/BlogPost" + }, + "type": "array" + } + }, + "required": [ + "posts" + ], + "type": "object" + }, + "PostResponse": { + "properties": { + "post": { + "$ref": "#/components/schemas/BlogPost" + } + }, + "required": [ + "post" + ], + "type": "object" + } + }, + "securitySchemes": { + "apiKeyAuth": { + "description": "Use the configured `BLOG_API_KEY` value.", + "in": "header", + "name": "x-api-key", + "type": "apiKey" + }, + "bearerAuth": { + "description": "Use `Authorization: Bearer $BLOG_API_KEY`.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Publishing and read API for the a2a cloud blog. Published content is public; draft, preview, create, update, and delete operations require the blog API key.", + "title": "a2a cloud blog API", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/healthz": { + "get": { + "operationId": "getBlogHealth", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthOk" + } + } + }, + "description": "The service can initialize its schema and query Postgres." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthError" + } + } + }, + "description": "The service or database is unavailable." + } + }, + "summary": "Check blog service health", + "tags": [ + "Health" + ] + } + }, + "/api/posts": { + "get": { + "description": "Returns published posts by default. `status=all` and `status=draft` include draft content and require either bearer auth or the `x-api-key` header.", + "operationId": "listBlogPosts", + "parameters": [ + { + "description": "Use `published` or omit the parameter for public published posts. `all` and `draft` require authentication.", + "in": "query", + "name": "status", + "required": false, + "schema": { + "default": "published", + "enum": [ + "published", + "draft", + "all" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostListResponse" + } + } + }, + "description": "Posts returned in newest-first order." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "503": { + "$ref": "#/components/responses/AuthNotConfigured" + } + }, + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "summary": "List posts", + "tags": [ + "Posts" + ] + }, + "post": { + "operationId": "createBlogPost", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlogPostInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostResponse" + } + } + }, + "description": "The post was created." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "A post with this slug already exists." + }, + "503": { + "$ref": "#/components/responses/AuthNotConfigured" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "summary": "Create a blog post", + "tags": [ + "Posts" + ] + } + }, + "/api/posts/{slug}": { + "delete": { + "operationId": "deleteBlogPost", + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteResponse" + } + } + }, + "description": "The post was deleted." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "503": { + "$ref": "#/components/responses/AuthNotConfigured" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "summary": "Delete a post by slug", + "tags": [ + "Posts" + ] + }, + "get": { + "description": "Returns a published post. Add `preview=1` to fetch drafts or future-dated posts; preview requests require either bearer auth or the `x-api-key` header.", + "operationId": "getBlogPost", + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + }, + { + "description": "Set to `1` to bypass the public published-post filter. Requires authentication.", + "in": "query", + "name": "preview", + "required": false, + "schema": { + "enum": [ + "1" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostResponse" + } + } + }, + "description": "The matching post." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "503": { + "$ref": "#/components/responses/AuthNotConfigured" + } + }, + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "summary": "Get a post by slug", + "tags": [ + "Posts" + ] + }, + "put": { + "operationId": "upsertBlogPost", + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlogPostInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostResponse" + } + } + }, + "description": "The post was created or updated." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "503": { + "$ref": "#/components/responses/AuthNotConfigured" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "summary": "Upsert a post by slug", + "tags": [ + "Posts" + ] + } + } + }, + "servers": [ + { + "description": "Production", + "url": "https://blog.a2acloud.io" + }, + { + "description": "Local development", + "url": "http://localhost:3000" + } + ], + "tags": [ + { + "description": "Blog post listing, publishing, and lookup.", + "name": "Posts" + }, + { + "description": "Service health checks.", + "name": "Health" + } + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..086fbda --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +# a2a-pack is installed by the platform base image. +deepagents>=0.5.0 +langchain>=0.3 +langchain-openai>=0.2 +langchain-core>=0.3 +langgraph>=0.6 +httpx>=0.27