diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1e747bbfa..46c8abf83 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,11 +29,13 @@ For everything that isn't an attempt to use the kit (general questions, code exp - `/menu` - `/troubleshoot` - `/flightcheck` + - `/org-announcements` 2. **Intent hint — natural-language equivalent.** The user isn't typing a slash-command but is unambiguously asking to *run* the kit from this workspace. Examples: - "How do I set up the kit?" / "How do I run setup?" / "Start the ESS Maker Kit" - "Run flightcheck" / "Run the readiness check on my agent" - "Create a topic" / "Connect ServiceNow" / "Scan my agent for errors" — when phrased as a request to *do it now* in this workspace, not as a general "how does this work?" question. + - "Create an organization announcement" / "Post an announcement" / "Manage organization announcements" — when phrased as a request to act in this workspace. When in doubt, prefer the default behavior (answer normally) over firing the redirect. A user asking "what does /flightcheck do?" is asking a documentation question — answer it from the README and `solutions/ess-maker-skills/` files; do **not** redirect. @@ -49,7 +51,7 @@ When (and only when) the trigger conditions above are met, respond with **only** > 2. Navigate **inside** this folder, then **into** `solutions`, and select `ess-maker-skills` > 3. Click `Select Folder` > 4. VS Code will reopen with the kit loaded -> 5. Type `/setup` again — it will work this time +> 5. Type your command again — use `/setup` for setup or `/org-announcements` for announcements > > See the [README](README.md) for the full getting-started walkthrough. > diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 232b1abdf..13ffcb96d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,7 @@ jobs: tests/mcp/agentconfig tests/scripts/test_setup_preserves_title_id.py tests/scripts/test_validate_branding.py + tests/scripts/test_maker_profile_vsix.py -q - name: Test ESS Maker Quick Actions @@ -165,6 +166,7 @@ jobs: run: >- python -m pytest tests/mcp/agentconfig_org_announcements + tests/setup/test_foundation_setup_router.py -q flightcheck-tests: diff --git a/setup/README.md b/setup/README.md index a99d3efc8..f63a1e424 100644 --- a/setup/README.md +++ b/setup/README.md @@ -36,7 +36,7 @@ iex (irm https://raw.githubusercontent.com/microsoft/Employee-Self-Service-Agent This installs everything the standard installer does, plus the **ESS Maker Profile** extension which provides: - A chat-only layout with all developer surfaces hidden -- Big-button "Quick Actions" rail for common tasks (Connect, Customize landing page, Create, Scan, FlightCheck, Push) +- Big-button "Quick Actions" rail for common tasks (Setup, Customize landing page, Post an announcement, Create, Scan, FlightCheck, Push) - A built-in tutorial explaining each button You can switch between lite mode and standard VS Code at any time using the toggle buttons in the Quick Actions panel. diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index b0e2a58d3..f1c3126e2 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -98,8 +98,8 @@ Order of grounding sources (highest to lowest): microsoft/CopilotStudioSamples Employee Self-Service Agent samples. 3. `src/skills/` - kit-shipped skill instructions for /create, /update, /delete, /test, /scan, /evaluate, /push, /flightcheck, - /backup-template-configs, /restore-template-configs, and landing-page - configuration. + /backup-template-configs, /restore-template-configs, /org-announcements, + and landing-page configuration. 4. `src/reference/` (other subfolders) - additional kit-shipped guidance. 5. Web fetch / general knowledge - only when none of the above answer the question and only after telling the user you're falling back. @@ -323,6 +323,8 @@ After a successful push, `.baseline/` is updated to match the new state. | Re-apply / put back Workday reference-data customisations | `src/skills/restore-template-configs/SKILL.md` | | View or configure ESS landing-page branding, quick links, starter prompts, insight cards, name, or icon | `src/skills/landing-page-config/SKILL.md` | | Invoke any tool from the `ess-landing-page-config` MCP server | `src/skills/landing-page-config/SKILL.md` | +| Create, edit, republish, archive, or manage organization announcements or bulletins | `src/skills/org-announcements/SKILL.md` | +| Invoke any tool from the `ess-org-announcements` MCP server | `src/skills/org-announcements/SKILL.md` | **Trigger phrases for connect:** "connect ServiceNow", "set up ServiceNow", "integrate ServiceNow", "connect Workday", "set up Workday", "add ServiceNow", @@ -346,6 +348,18 @@ links, starter prompts, Stay Up to Date, Quick Access, the agent name, or the agent icon, or asks what any landing-page setting controls for employees. Do not call an AgentConfiguration MCP tool from a generic flow. +**Org Announcements invocation:** Before invoking ANY tool from the +`ess-org-announcements` MCP server, read and follow +`src/skills/org-announcements/SKILL.md`. Its own `list_agent_configs` and +`search_agents` tools resolve missing deployed titleIds; do not start or call +the landing-page server for announcement discovery. This applies whether the user asks to +see, create, edit, republish, archive, or delete an announcement, mentions +announcements, org announcements, bulletins, or alerts, or asks who an +announcement reaches. Org Announcements are scoped to the authenticated tenant +and selected deployed agent's required `titleId`. The tenant is token-derived; +the title is not an audience group or author permission. Do not call an Org +Announcements MCP tool from a generic flow. + **FlightCheck results rendering:** When presenting `/flightcheck` results (Step 3 of `src/skills/flightcheck/SKILL.md`), read `workspace/flightcheck/results.json` with your file-reading tool and format the summary banner and tables **yourself, diff --git a/solutions/ess-maker-skills/.github/prompts/menu.prompt.md b/solutions/ess-maker-skills/.github/prompts/menu.prompt.md index def4ef545..b5a560d62 100644 --- a/solutions/ess-maker-skills/.github/prompts/menu.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/menu.prompt.md @@ -14,6 +14,7 @@ Here's what I can help you with: | Command | What it does | |---------|-------------| | `/landing-page` | Configure the branding and content employees see when they open the ESS agent | +| `/org-announcements` | Create and manage announcements for the selected deployed ESS agent | | `/connect` | Type Enter to connect ServiceNow or Workday to your agent | | `/create` | Create a simple topic with evals, a workflow, or an evaluation test set | | `/update` | Update a simple topic with evals, a workflow, or an evaluation test set | diff --git a/solutions/ess-maker-skills/.github/prompts/org-announcements.prompt.md b/solutions/ess-maker-skills/.github/prompts/org-announcements.prompt.md new file mode 100644 index 000000000..c3ca03cc7 --- /dev/null +++ b/solutions/ess-maker-skills/.github/prompts/org-announcements.prompt.md @@ -0,0 +1,8 @@ +--- +mode: agent +description: "Create and manage announcements for the selected ESS agent" +--- + +# Org Announcements + +Read `src/skills/org-announcements/SKILL.md` and follow it. diff --git a/solutions/ess-maker-skills/.vscode/mcp.defaults.json b/solutions/ess-maker-skills/.vscode/mcp.defaults.json index 13dbccae3..b72a37cb2 100644 --- a/solutions/ess-maker-skills/.vscode/mcp.defaults.json +++ b/solutions/ess-maker-skills/.vscode/mcp.defaults.json @@ -4,6 +4,11 @@ "command": "{pythonExecutable}", "args": ["server.py"], "cwd": "${workspaceFolder}/src/mcp/agentconfig_landing_page" + }, + "ess-org-announcements": { + "command": "{pythonExecutable}", + "args": ["server.py"], + "cwd": "${workspaceFolder}/src/mcp/agentconfig_org_announcements" } } } diff --git a/solutions/ess-maker-skills/README.md b/solutions/ess-maker-skills/README.md index f696b8f00..21b4580b2 100644 --- a/solutions/ess-maker-skills/README.md +++ b/solutions/ess-maker-skills/README.md @@ -48,6 +48,47 @@ Copilot Studio and deployed to the organization. `/setup` installs and extracts the Power Platform agent; publication, admin approval, and Integrated apps deployment are separate steps. +### 📢 Post Organization Announcements + +Publish announcements for the selected deployed ESS agent and its audiences. Run +`/org-announcements`, ask `Create an announcement`, or use the **Post an +announcement** Quick Action. + +- **Standard announcements** carry a title, description, priority, and up to two + actions. +- **Alerts** carry a single link action for time-sensitive notices. +- **Audiences** are security groups, mail-enabled security groups, or classic + distribution groups, searched by name or email in one combined query. +- **Scheduling** publishes an announcement for a start/end window, and expired + announcements can be published again through the normal editor after reviewing + and updating their schedule. +- **Lifecycle** actions archive, unarchive, move back to draft, duplicate, or + delete an announcement. + +Describe the announcement in chat and the kit opens a pre-filled editor for you +to review — nothing is saved until you publish or save a draft in that editor. + +Org Announcements are **scoped to the authenticated tenant and selected agent's +`titleId`**, not shared across agents. The current 100 limit and latest 50 +archive window apply per tenant-and-agent pair. There is no tenant-wide fallback. +The title is resolved using `list_agent_configs` and `search_agents` on the +`ess-org-announcements` provider. Discovery shares neutral Python code with the +landing-page provider, but does not require its MCP process or initialize its +configuration. Announcement authoring +requires the Org Announcements feature to be enabled for your tenant, and +audience search requires the `Directory.Read.All` Microsoft Graph permission to +be consented in your tenant. + +Graph uses a separate resource token for the same authoring tenant and account. +The current account-context check requires readable `tid` and `oid` claims; +opaque tokens or credentials missing those claims return an explicit +authentication failure rather than using a different account. The API still +validates tokens and authorizes every request. + +This development surface requires the matching agent-qualified v1.1 backend +and scoped widget. The MCP rejects unscoped canonical responses instead of +silently consuming records from an older backend. + ### 📖 Pre-Loaded ESS Documentation, Samples & Best Practices The kit ships with a complete reference library that the AI agent reads at task time — you don't need to look anything up yourself. @@ -332,6 +373,7 @@ Then **run `/setup`** in GitHub Copilot Chat to configure your environment. |---------|-------------| | `/setup` | First-time environment setup — authenticate, discover agent, extract, configure | | `/landing-page` | Configure landing-page branding and content | +| `/org-announcements` | Create and manage announcements for the selected ESS agent | | `/connect` | Connect an external system (ServiceNow, Workday) — guided setup with MCP verification | | `/create` | Create an eval-driven topic, workflow, or evaluation test set | | `/update` | Update a simple topic with evals, a workflow, or an evaluation test set | diff --git a/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/client.py b/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/client.py index d5be7237d..4869e2f9c 100644 --- a/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/client.py +++ b/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/client.py @@ -7,7 +7,7 @@ httpx session, and the retrying ``_request`` — lives in the neutral ``agentconfig_core`` core (``base_client.AgentConfigBaseClient``). This module keeps only what is specific to the Org Announcements authoring surface: the -v1.1 base URL, the three agent-qualified ``essbulletins`` routes, and the +v1.1 base URL, the three agent-qualified ``EssBulletins`` routes, and the manager-state classification. The collection is keyed by the authenticated tenant and deployed ESS titleId. @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import os import sys from datetime import datetime, timezone @@ -49,6 +50,14 @@ # items, with no envelope metadata. Hitting the cap means the archived window is # truncated; it does not reveal an exact archived total. ARCHIVED_WINDOW_SIZE = 50 +STATUS_DELETED = "deleted" + +_VALID_STATUSES = frozenset({"draft", "published", "retired", STATUS_DELETED}) +_VALID_BULLETIN_TYPES = frozenset({"standard", "alert"}) +_VALID_ACTION_TYPES = frozenset({"externalLink", "copilotChat"}) +_VALID_PRIORITIES = frozenset({0, 1}) +_COMMITTED_RELOAD_404_DELAYS = (0.05, 0.15) + class IndeterminateWriteError(AgentConfigApiError): """An unkeyed create may have committed but was never acknowledged. @@ -61,10 +70,10 @@ class IndeterminateWriteError(AgentConfigApiError): class BulletinValidationError(AgentConfigApiError): - """The save endpoint returned HTTP 200 carrying structured field errors. + """The OData ``Save`` action returned HTTP 200 with field errors. WeveNova's ``EssBulletinSaveResult`` reports validation failure *inside* a - success response: ``errors`` is non-empty and ``config`` is absent. Every + success response: ``Errors`` is non-empty and ``Id`` is absent. Every ``{code, field, message}`` entry is preserved verbatim so the widget can render its localized copy against the exact backend code and attach the message to the exact field. Flattening them into one generic message would @@ -80,6 +89,158 @@ def __init__(self, errors: list[dict[str, Any]]): self.errors = errors +class CommittedCanonicalReloadError(Exception): + """A Save committed, but its canonical keyed reload failed.""" + + def __init__(self, cause: AgentConfigApiError | httpx.RequestError): + super().__init__( + "The announcement was saved, but its canonical state could not be reloaded." + ) + self.cause = cause + + +_BULLETIN_FIELD_MAP = { + "Type": "type", + "Priority": "priority", + "Title": "title", + "Description": "description", + "PrimaryAction": "primaryAction", + "SecondaryAction": "secondaryAction", + "StartDate": "startDate", + "EndDate": "endDate", +} + +_ACTION_FIELD_MAP = { + "ActionType": "actionType", + "Label": "label", + "Url": "url", + "Prompt": "prompt", +} + + +def _require_odata_enum( + value: Any, field: str, allowed: frozenset[Any] +) -> Any: + if not any( + type(value) is type(candidate) and value == candidate + for candidate in allowed + ): + raise AgentConfigApiError( + f"Org Announcements API returned an invalid {field}" + ) + return value + + +def _require_input_enum( + value: Any, field: str, allowed: frozenset[Any] +) -> Any: + if not any( + type(value) is type(candidate) and value == candidate + for candidate in allowed + ): + raise ValueError(f"{field} has an unsupported value") + return value + + +def _from_odata_action(payload: Any) -> Optional[dict[str, Any]]: + if payload is None: + return None + if not isinstance(payload, dict): + raise AgentConfigApiError( + "Org Announcements API returned an invalid bulletin action" + ) + _require_odata_enum( + payload.get("ActionType"), "action type", _VALID_ACTION_TYPES + ) + return { + target: payload[source] + for source, target in _ACTION_FIELD_MAP.items() + if source in payload + } + + +def _from_odata_bulletin(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + raise AgentConfigApiError( + "Org Announcements API returned invalid bulletin content" + ) + if "Id" in payload or "id" in payload: + raise AgentConfigApiError( + "Org Announcements API returned a duplicate id inside bulletin content" + ) + + result: dict[str, Any] = {} + for source, target in _BULLETIN_FIELD_MAP.items(): + if source not in payload: + continue + value = payload[source] + if source in ("PrimaryAction", "SecondaryAction"): + value = _from_odata_action(value) + elif source == "Type": + value = _require_odata_enum( + value, "bulletin type", _VALID_BULLETIN_TYPES + ) + elif source == "Priority": + value = _require_odata_enum( + value, "bulletin priority", _VALID_PRIORITIES + ) + result[target] = value + return result + + +def _to_odata_action(payload: Any) -> Optional[dict[str, Any]]: + if payload is None: + return None + if not isinstance(payload, dict): + raise ValueError("bulletin actions must be objects") + _require_input_enum( + payload.get("actionType"), "actionType", _VALID_ACTION_TYPES + ) + return { + source: payload[target] + for source, target in _ACTION_FIELD_MAP.items() + if target in payload + } + + +def _to_odata_bulletin(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError("bulletin must be an object") + result: dict[str, Any] = {} + for source, target in _BULLETIN_FIELD_MAP.items(): + if target not in payload: + continue + value = payload[target] + if target in ("primaryAction", "secondaryAction"): + value = _to_odata_action(value) + elif target == "type": + value = _require_input_enum( + value, "type", _VALID_BULLETIN_TYPES + ) + elif target == "priority": + value = _require_input_enum( + value, "priority", _VALID_PRIORITIES + ) + result[source] = value + return result + + +def _to_odata_save_input(payload: dict[str, Any]) -> dict[str, Any]: + result: dict[str, Any] = {} + if "id" in payload: + result["Id"] = validate_bulletin_id(payload["id"]) + if "bulletin" in payload: + result["Bulletin"] = _to_odata_bulletin(payload["bulletin"]) + if "audience" in payload: + result["Audience"] = payload["audience"] + if "status" not in payload: + raise ValueError("status is required") + result["Status"] = _require_input_enum( + payload["status"], "status", _VALID_STATUSES + ) + return result + + def _parse_instant(value: Any) -> Optional[datetime]: """Parse a UTC ISO instant, returning ``None`` for absent or unparseable text. @@ -133,12 +294,13 @@ def is_archived_item(config: dict[str, Any], now: datetime) -> bool: class OrgAnnouncementsClient(AgentDiscoveryClient): - """Async client for the tenant-and-agent-scoped ``essbulletins`` routes. + """Async client for the tenant-and-agent-scoped ``EssBulletins`` routes. Inherits auth, the token decode, the httpx session, and the retrying ``_request`` from ``AgentConfigBaseClient``; adds only the v1.1 base URL and - the three authoring routes. Response bodies are already lower-camel on this - surface, so no key transform is applied. + the three authoring routes. WeveNova's OData properties are PascalCase; + this client explicitly adapts them to the existing lower-camel MCP/widget + contract at the HTTP boundary. """ def __init__(self, *, transport: Optional[httpx.AsyncBaseTransport] = None): @@ -156,45 +318,81 @@ def __init__(self, *, transport: Optional[httpx.AsyncBaseTransport] = None): def _collection_path(self, title_id: str) -> str: encoded = _require_odata_id(validate_title_id(title_id), "titleId") - return f"tenants('{self.tenant_id}')/EmployeeAgents('{encoded}')/essbulletins" + return f"tenants('{self.tenant_id}')/EmployeeAgents('{encoded}')/EssBulletins" @staticmethod - def _require_config(payload: Any, title_id: str) -> dict[str, Any]: + def _require_config( + payload: Any, + title_id: str, + *, + expected_bulletin_id: Optional[str] = None, + ) -> dict[str, Any]: """Reject a success-shaped response that is not a canonical record. A malformed body must not become an empty default, because the widget would then render a blank editor over real stored content. """ - if not isinstance(payload, dict) or not isinstance( - payload.get("bulletin"), dict - ): + if not isinstance(payload, dict): raise AgentConfigApiError( "Org Announcements API returned an invalid bulletin configuration" ) - if payload.get("titleId") != title_id: + if payload.get("TitleId") != title_id: raise AgentConfigApiError( "Org Announcements API returned a configuration with missing " "or mismatched titleId" ) - if "titleId" in payload["bulletin"]: + raw_id = payload.get("Id") + if not isinstance(raw_id, str): + raise AgentConfigApiError( + "Org Announcements API returned a configuration without a valid id" + ) + try: + bulletin_id = validate_bulletin_id(raw_id) + except ValueError as error: + raise AgentConfigApiError( + "Org Announcements API returned a configuration without a valid id" + ) from error + if expected_bulletin_id is not None and bulletin_id != expected_bulletin_id: + raise AgentConfigApiError( + "Org Announcements API returned a configuration for a different " + "bulletin id" + ) + audience = payload.get("Audience") + if not isinstance(audience, list) or not all( + isinstance(group_id, str) for group_id in audience + ): raise AgentConfigApiError( - "Org Announcements API returned titleId inside bulletin content" + "Org Announcements API returned an invalid audience" ) - return payload + status = _require_odata_enum( + payload.get("Status"), "status", _VALID_STATUSES + ) + + config = { + "id": bulletin_id, + "titleId": title_id, + "bulletin": _from_odata_bulletin(payload.get("Bulletin")), + "audience": audience, + "status": status, + } + for source, target in ( + ("CreatedBy", "createdBy"), + ("CreatedOn", "createdOn"), + ("ModifiedDate", "modifiedDate"), + ): + if source in payload: + config[target] = payload[source] + return config @classmethod def _unwrap_collection(cls, payload: Any, title_id: str) -> list[dict[str, Any]]: - if isinstance(payload, list): - items = payload - elif isinstance(payload, dict) and isinstance(payload.get("value"), list): + if isinstance(payload, dict) and isinstance(payload.get("value"), list): items = payload["value"] else: raise AgentConfigApiError( "Org Announcements API returned an invalid collection response" ) - for item in items: - cls._require_config(item, title_id) - return items + return [cls._require_config(item, title_id) for item in items] @staticmethod def _normalize_save_errors(raw: Any) -> list[dict[str, Any]]: @@ -210,9 +408,9 @@ def _normalize_save_errors(raw: Any) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] for entry in raw: if isinstance(entry, dict): - code = entry.get("code") - field = entry.get("field") - message = entry.get("message") + code = entry.get("Code") + field = entry.get("Field") + message = entry.get("Message") normalized.append( { "code": ( @@ -240,105 +438,91 @@ def _normalize_save_errors(raw: Any) -> list[dict[str, Any]]: @classmethod def _unwrap_save_result( - cls, payload: Any, *, requested_id: Optional[str], title_id: str - ) -> dict[str, Any]: + cls, payload: Any, *, requested_id: Optional[str] + ) -> str: """Validate and unwrap an ``EssBulletinSaveResult``. - The save endpoint answers HTTP 200 with ``{id, config, errors}`` — a - shape that list/load do *not* use, and that reports validation failure - inside a success status. Three outcomes are distinguished: + The OData action answers HTTP 200 with ``{Id, Errors}`` and reports + validation failure inside a success status. Three outcomes are + distinguished: - * ``errors`` non-empty → :class:`BulletinValidationError` carrying every + * ``Errors`` non-empty → :class:`BulletinValidationError` carrying every entry, so field-level backend codes survive to the widget. - * ``errors`` empty and ``config`` canonical → the canonical record, - after checking the wrapper ``id`` agrees with the config's own ID and - with the ID the caller asked to update. + * ``Errors`` empty and ``Id`` valid → the affected canonical key, after + checking it agrees with the ID the caller asked to update. * anything else → :class:`AgentConfigApiError`, never an empty default: a blank record would render an empty editor over real stored content. - - ``requested_id`` is checked because an update that silently comes back - keyed to a *different* record means the caller is about to replace its - canonical state with someone else's announcement. """ if not isinstance(payload, dict): raise AgentConfigApiError( "Org Announcements API returned an invalid save response" ) - errors = payload.get("errors") - if errors is not None and not isinstance(errors, list): + errors = payload.get("Errors") + if not isinstance(errors, list): raise AgentConfigApiError( "Org Announcements API returned an invalid save error list" ) if errors: raise BulletinValidationError(cls._normalize_save_errors(errors)) - config = cls._require_config(payload.get("config"), title_id) - - config_id = config["bulletin"].get("id") - wrapper_id = payload.get("id") - canonical_id = ( - wrapper_id - if isinstance(wrapper_id, str) and wrapper_id - else config_id if isinstance(config_id, str) and config_id else None - ) - if canonical_id is None: - # A saved record with no identity cannot be edited, transitioned, or - # duplicated afterwards. Failing here is far better than handing the - # widget a row whose every subsequent action would 404. + raw_id = payload.get("Id") + if not isinstance(raw_id, str): raise AgentConfigApiError( "Org Announcements API returned a saved announcement without an id" ) - if ( - isinstance(wrapper_id, str) - and wrapper_id - and isinstance(config_id, str) - and config_id - and wrapper_id != config_id - ): + try: + canonical_id = validate_bulletin_id(raw_id) + except ValueError as error: raise AgentConfigApiError( - "Org Announcements API returned a save result whose id does not " - "match the saved configuration" - ) + "Org Announcements API returned a saved announcement without a valid id" + ) from error if requested_id is not None and canonical_id != requested_id: raise AgentConfigApiError( "Org Announcements API returned a different announcement " "than the one that was updated" ) - return config + return canonical_id async def list_bulletins(self, title_id: str) -> list[dict[str, Any]]: """List every current item plus the most recent archived window.""" payload = await self._request( - "GET", self._collection_path(title_id), transform_payload=False + "GET", + f"{self._collection_path(title_id)}/ManagementView()", + transform_payload=False, ) return self._unwrap_collection(payload, title_id) async def get_bulletin(self, title_id: str, bulletin_id: str) -> dict[str, Any]: """Load one canonical stored configuration.""" - path = f"{self._collection_path(title_id)}/{validate_bulletin_id(bulletin_id)}" + validated_id = validate_bulletin_id(bulletin_id) + path = f"{self._collection_path(title_id)}({validated_id})" return self._require_config( - await self._request("GET", path, transform_payload=False), title_id + await self._request("GET", path, transform_payload=False), + title_id, + expected_bulletin_id=validated_id, ) async def save_bulletin( self, title_id: str, payload: dict[str, Any] ) -> dict[str, Any]: - """Create or update through the authoring ``save`` endpoint. + """Create or update through the authoring OData ``Save`` action. - Returns the canonical configuration unwrapped from the endpoint's - ``EssBulletinSaveResult`` envelope. A payload without ``id`` is an + Returns the canonical configuration from a keyed GET after validating + the ``EssBulletinSaveResult`` receipt. A payload without ``id`` is an unkeyed create; replaying one after an ambiguous failure could duplicate - a committed record, so ambiguous retries are disabled and the failure is - re-raised as :class:`IndeterminateWriteError` for the caller to surface. + a committed record, so ambiguous retries are disabled and surfaced as + :class:`IndeterminateWriteError`. """ - requested_id = payload.get("id") + requested_id = ( + validate_bulletin_id(payload["id"]) if payload.get("id") else None + ) is_create = not requested_id try: result = await self._request( "POST", - f"{self._collection_path(title_id)}/save", - json=payload, + f"{self._collection_path(title_id)}/Save", + json={"input": _to_odata_save_input(payload)}, transform_payload=False, idempotent=not is_create, ) @@ -349,15 +533,33 @@ async def save_bulletin( "never received. Refresh before retrying." ) from error raise - return self._unwrap_save_result( + saved_id = self._unwrap_save_result( result, requested_id=requested_id if not is_create else None, - title_id=title_id, ) + return await self._reload_committed_bulletin(title_id, saved_id) + + async def _reload_committed_bulletin( + self, title_id: str, bulletin_id: str + ) -> dict[str, Any]: + """Reload a committed row, retrying only short-lived keyed GET 404s.""" + for attempt in range(len(_COMMITTED_RELOAD_404_DELAYS) + 1): + try: + return await self.get_bulletin(title_id, bulletin_id) + except (AgentConfigApiError, httpx.RequestError) as error: + is_retryable_404 = ( + isinstance(error, AgentConfigApiError) + and error.http_status == 404 + and attempt < len(_COMMITTED_RELOAD_404_DELAYS) + ) + if not is_retryable_404: + raise CommittedCanonicalReloadError(error) from error + await asyncio.sleep(_COMMITTED_RELOAD_404_DELAYS[attempt]) + raise AssertionError("committed reload retry loop exhausted") async def transition_bulletin( self, title_id: str, bulletin_id: str, status: str - ) -> dict[str, Any]: + ) -> Optional[dict[str, Any]]: """Apply a minimal lifecycle status change. WeveNova performs the transition inside ``SaveAsync``: it loads the @@ -366,22 +568,32 @@ async def transition_bulletin( ``{id, status}`` therefore avoids a separate read/merge/write and cannot clobber content with stale client state. - The response is the same ``EssBulletinSaveResult`` envelope the content - save returns, so it is unwrapped identically — including the ID check - that proves the transition landed on the requested record. + The response is the same ``EssBulletinSaveResult`` receipt the content + save returns. Non-delete transitions are followed by a keyed GET; + delete is a tombstone and therefore has no readable canonical resource. """ validated_id = validate_bulletin_id(bulletin_id) - return self._unwrap_save_result( + validated_status = _require_input_enum( + status, "status", _VALID_STATUSES + ) + saved_id = self._unwrap_save_result( await self._request( "POST", - f"{self._collection_path(title_id)}/save", - json={"id": validated_id, "status": status}, + f"{self._collection_path(title_id)}/Save", + json={ + "input": { + "Id": validated_id, + "Status": validated_status, + } + }, transform_payload=False, idempotent=True, ), requested_id=validated_id, - title_id=title_id, ) + if validated_status == STATUS_DELETED: + return None + return await self._reload_committed_bulletin(title_id, saved_id) def _is_ambiguous_write_failure( @@ -430,7 +642,7 @@ def build_manager_state( archived_count += 1 else: working_set_count += 1 - bulletin_id = config["bulletin"].get("id", "") + bulletin_id = config.get("id", "") view_models.append( { "config": config, diff --git a/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/drafts.py b/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/drafts.py index 81c67a830..a40b3ff54 100644 --- a/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/drafts.py +++ b/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/drafts.py @@ -388,7 +388,7 @@ def build_editor_draft_from_config( if not isinstance(bulletin, dict): raise ValueError("bulletin configuration is missing its bulletin content") - bulletin_id = bulletin.get("id") + bulletin_id = config.get("id") if not isinstance(bulletin_id, str) or not bulletin_id: raise ValueError("bulletin configuration is missing its id") diff --git a/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/server.py b/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/server.py index 165e5926d..05f5c9c09 100644 --- a/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/server.py +++ b/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/server.py @@ -46,8 +46,10 @@ from client import ( AgentConfigApiError, BulletinValidationError, + CommittedCanonicalReloadError, IndeterminateWriteError, OrgAnnouncementsClient, + STATUS_DELETED, build_manager_state, is_deleted_item, ) @@ -123,7 +125,7 @@ "archive": "retired", "unarchive": "draft", "moveToDraft": "draft", - "delete": "deleted", + "delete": STATUS_DELETED, } TransitionName = Literal["archive", "unarchive", "moveToDraft", "delete"] @@ -170,13 +172,19 @@ {*_MODEL_VISIBLE_BACKEND_MESSAGES, "CommittedRefreshFailed"} ) -# Identity and audit fields the backend owns. A duplicate strips them from the -# copied content so the copy is created as a fresh Draft rather than silently -# updating its source or inheriting its history. The wrapper-level audit fields -# (createdBy/createdOn/modifiedDate/status) are never copied at all, because the -# duplicate payload is rebuilt from content and audience only. -_COPY_STRIPPED_FIELDS = frozenset( - {"id", "createdBy", "createdOn", "modifiedDate", "status", "version", "etag"} +# Closed authored-content projection accepted by WeveNova's EssBulletinContent +# DTO. A duplicate never forwards provider-added fields back into Save. +_AUTHORED_BULLETIN_FIELDS = frozenset( + { + "type", + "priority", + "title", + "description", + "primaryAction", + "secondaryAction", + "startDate", + "endDate", + } ) @@ -785,8 +793,7 @@ async def _resolve_manager_metadata( metadata: dict[str, list[dict[str, Any]]] = {} for config in visible: - bulletin = config.get("bulletin") - bulletin_id = bulletin.get("id") if isinstance(bulletin, dict) else None + bulletin_id = config.get("id") audience = config.get("audience") metadata[bulletin_id or ""] = build_audience_metadata( [group_id for group_id in audience if isinstance(group_id, str)] @@ -1267,7 +1274,20 @@ async def save_bulletin( try: async with get_client_lease() as client: scope = {"tenantId": client.tenant_id, "titleId": titleId} - saved = await client.save_bulletin(titleId, payload) + try: + saved = await client.save_bulletin(titleId, payload) + except CommittedCanonicalReloadError as error: + failure = await _failure_from(error.cause, client) + _LOGGER.warning( + "save_bulletin canonical reload failed after commit: %s", + _diagnostic_code(failure), + ) + return _fail( + "save_bulletin", + _committed_refresh_failure(failure), + started, + scope, + ) try: result = await _saved_item_result( @@ -1347,9 +1367,23 @@ async def transition_bulletin( validate_bulletin_id(id) async with get_client_lease() as client: scope = {"tenantId": client.tenant_id, "titleId": titleId} - changed = await client.transition_bulletin( - titleId, id, TRANSITION_STATUS[transition] - ) + try: + changed = await client.transition_bulletin( + titleId, id, TRANSITION_STATUS[transition] + ) + except CommittedCanonicalReloadError as error: + cause = await _failure_from(error.cause, client) + _LOGGER.warning( + "transition_bulletin canonical reload failed after commit: %s (%s)", + _diagnostic_code(cause), + transition, + ) + return _fail( + "transition_bulletin", + _committed_refresh_failure(cause), + started, + scope, + ) # The transition is committed from here on. A refresh failure must never be # reported as a retryable normal failure, because the lifecycle change has @@ -1379,17 +1413,14 @@ async def transition_bulletin( outcome="success", latency_ms=_elapsed_ms(started), ) - # The canonical changed row is included alongside the manager state. It is - # additive: the widget's existing manager-shaped contract is untouched, so a - # host that strips unknown fields simply ignores ``item`` and still gets a - # correct refresh. + # Non-delete transitions include the canonical changed row alongside + # the manager state. Delete returns only the refreshed manager because + # the tombstoned resource is no longer readable. + payload = {**scope, "status": "success", "manager": manager} + if changed is not None: + payload["item"] = {"config": changed} return _text_result( - { - **scope, - "status": "success", - "item": {"config": changed}, - "manager": manager, - }, + payload, "Updated the organization announcement.", ) except _MUTATION_ERRORS as error: @@ -1412,10 +1443,9 @@ async def duplicate_bulletin( ) -> CallToolResult: """Copy an existing announcement into a new Draft. - Loads the canonical source, strips its identity and audit fields, and - creates a new Draft. A missing source is a not-found failure, never a - create: duplicating something that no longer exists must not invent a - record. + Loads the canonical source, projects only WeveNova-authored content fields, + and creates a new Draft. A missing source is a not-found failure, never a + create: duplicating something that no longer exists must not invent a record. """ started = time.monotonic() scope = {"titleId": titleId} @@ -1427,15 +1457,13 @@ async def duplicate_bulletin( scope = {"tenantId": client.tenant_id, "titleId": titleId} source = await client.get_bulletin(titleId, id) - # Stored content is forwarded verbatim, so it never passes through - # BulletinInput. The blank-schedule sentinel is stripped explicitly here for - # the same reason it is coerced there: "" is not a DateTimeOffset, and - # sending it would fail model binding on a copy the maker never edited. + # Stored content does not pass through BulletinInput. Project the + # provider's closed DTO and strip blank schedule sentinels before Save. bulletin = without_blank_schedule( { key: value for key, value in source["bulletin"].items() - if key not in _COPY_STRIPPED_FIELDS + if key in _AUTHORED_BULLETIN_FIELDS } ) audience = source.get("audience") @@ -1453,6 +1481,18 @@ async def duplicate_bulletin( try: created = await client.save_bulletin(titleId, payload) + except CommittedCanonicalReloadError as error: + failure = await _failure_from(error.cause, client) + _LOGGER.warning( + "duplicate_bulletin canonical reload failed after commit: %s", + _diagnostic_code(failure), + ) + return _fail( + "duplicate_bulletin", + _committed_refresh_failure(failure), + started, + scope, + ) except _MUTATION_ERRORS as error: failure = await _failure_from(error, client) _LOGGER.warning( diff --git a/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/validation.py b/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/validation.py index 94cf986e2..eef49bf36 100644 --- a/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/validation.py +++ b/solutions/ess-maker-skills/src/mcp/agentconfig_org_announcements/validation.py @@ -7,6 +7,7 @@ import os import sys +from uuid import UUID # The AgentConfiguration MCP family lives at the ``src/mcp`` root as sibling # folders sharing the neutral ``agentconfig_core`` client core. There is no @@ -29,9 +30,9 @@ def validate_title_id(title_id: str) -> str: def validate_bulletin_id(bulletin_id: str) -> str: - """Validate a path-bound backend-assigned bulletin identifier.""" + """Validate and canonicalize the backend-assigned OData Guid key.""" if not isinstance(bulletin_id, str) or not bulletin_id: - raise ValueError("bulletinId must be a non-empty string") + raise ValueError("bulletinId must be a non-empty GUID string") if bulletin_id != bulletin_id.strip(): raise ValueError("bulletinId must not have surrounding whitespace") if bulletin_id in {".", ".."}: @@ -48,4 +49,10 @@ def validate_bulletin_id(bulletin_id: str) -> str: raise ValueError( "bulletinId must not contain path, query, fragment, or escape separators" ) - return bulletin_id + try: + parsed = UUID(bulletin_id) + except (ValueError, AttributeError) as error: + raise ValueError("bulletinId must be a valid GUID") from error + if parsed.int == 0: + raise ValueError("bulletinId must not be the empty GUID") + return str(parsed) diff --git a/solutions/ess-maker-skills/src/skills/foundation-setup/SKILL.md b/solutions/ess-maker-skills/src/skills/foundation-setup/SKILL.md index bb699d248..3fbea66c1 100644 --- a/solutions/ess-maker-skills/src/skills/foundation-setup/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/foundation-setup/SKILL.md @@ -15,7 +15,7 @@ This is the single integration-neutral `/setup` entry point. It owns only: - preferred unmanaged solution configuration; - HR and/or IT ESS starter installation; - baseline readiness; -- the handoff to `/connect`. +- the handoff to the available maker entry points. Workday, ServiceNow, SAP SuccessFactors, authentication, extension packs, and topics are explicitly outside this skill. @@ -43,8 +43,8 @@ overwrite corrupt state silently. If `connect_ready` is true, inspect `.local/config.json`: - If its `setup` value is `"complete"`, show that foundation and workspace setup - are complete. Tell the maker they can run `/landing-page` or `/connect`, or - type `/menu` to see every capability. + are complete. Tell the maker they can run `/landing-page`, + `/org-announcements`, or `/connect`, or type `/menu` to see every capability. - Otherwise read `src/skills/onboarding/foundation-bootstrap.md` and follow it. The bootstrap must reuse `environment.tenant_endpoint`, must not render another setup checklist, and must proceed directly to the installed-agent diff --git a/solutions/ess-maker-skills/src/skills/foundation-setup/handoff.md b/solutions/ess-maker-skills/src/skills/foundation-setup/handoff.md index 04dd862f4..15f448edd 100644 --- a/solutions/ess-maker-skills/src/skills/foundation-setup/handoff.md +++ b/solutions/ess-maker-skills/src/skills/foundation-setup/handoff.md @@ -47,7 +47,8 @@ checklist. When it completes: Your ESS foundation and local workspace are complete. Run `/landing-page` to configure branding and the content employees see. Run -`/connect` to choose an integration. Topic creation remains a separate -`/create` workflow. +`/org-announcements` to post an announcement for your selected deployed agent, +or run `/connect` to choose an integration. Topic creation remains a separate +`/create` workflow. Type `/menu` to see every capability. **End message.** diff --git a/solutions/ess-maker-skills/src/skills/onboarding/SKILL.md b/solutions/ess-maker-skills/src/skills/onboarding/SKILL.md index 209569850..b71bb115f 100644 --- a/solutions/ess-maker-skills/src/skills/onboarding/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/onboarding/SKILL.md @@ -51,6 +51,8 @@ Before showing the message, run this command without showing it to the user: ```powershell python -m pip install -r scripts/requirements.txt +python -m pip install -r src/mcp/agentconfig_landing_page/requirements.txt +python -m pip install -r src/mcp/agentconfig_org_announcements/requirements.txt ``` If dependency installation fails, show the error and stop. @@ -58,7 +60,8 @@ If dependency installation fails, show the error and stop. **Message:** Setup is already complete! Run `/landing-page` to configure the branding and -content employees see, or type `/menu` to see everything you can do. +content employees see, `/org-announcements` to post an announcement for your +selected deployed agent, or type `/menu` to see everything you can do. **End message.** diff --git a/solutions/ess-maker-skills/src/skills/onboarding/step1.md b/solutions/ess-maker-skills/src/skills/onboarding/step1.md index bd0d1ffff..5f3a46232 100644 --- a/solutions/ess-maker-skills/src/skills/onboarding/step1.md +++ b/solutions/ess-maker-skills/src/skills/onboarding/step1.md @@ -137,6 +137,7 @@ Run this command in the terminal without showing it to the user: ```powershell python -m pip install -r src/mcp/agentconfig_landing_page/requirements.txt +python -m pip install -r src/mcp/agentconfig_org_announcements/requirements.txt ``` If installation fails, show the error and stop. diff --git a/solutions/ess-maker-skills/src/skills/onboarding/step2.md b/solutions/ess-maker-skills/src/skills/onboarding/step2.md index 99f987de8..c70c60163 100644 --- a/solutions/ess-maker-skills/src/skills/onboarding/step2.md +++ b/solutions/ess-maker-skills/src/skills/onboarding/step2.md @@ -79,9 +79,13 @@ You can customize the active agent's landing page from chat, including accent colors, categorized starter prompts, quick links, Stay up to date cards, and Quick Access cards. Run `/landing-page` whenever you're ready. +You can also post announcements for the selected deployed ESS agent and its +chosen audiences. Run `/org-announcements` to create or manage them. + | Command | What it does | |---------|-------------| | `/landing-page` | Configure landing-page branding and content | +| `/org-announcements` | Create and manage announcements for the selected ESS agent | | `/create` | Create a new topic or workflow | | `/connect` | Set up ServiceNow or Workday integration | | `/scan` | Scan for compile errors | diff --git a/solutions/ess-maker-skills/src/skills/org-announcements/SKILL.md b/solutions/ess-maker-skills/src/skills/org-announcements/SKILL.md new file mode 100644 index 000000000..93d0ee701 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/org-announcements/SKILL.md @@ -0,0 +1,504 @@ +--- +name: org-announcements +description: >- + Create, edit, republish, and manage announcements for a deployed ESS agent + through the Org Announcements MCP server. Use for announcements, org + announcements, bulletins, alerts, announcement audiences, archiving or + republishing an announcement, and any call to the ess-org-announcements + MCP server. +--- + +# Org Announcements + +Orchestrate organization announcements through the Org Announcements MCP +server. Open the right view once, let the widget own the editing session, and +never claim a save the tools did not return. + +## Tenant-and-agent scope + +Org Announcements belong to **one deployed ESS agent in the authenticated +tenant**, selected by its required `titleId`. They are not shared across every +agent. `titleId` is an agent identifier, not an author permission, a Dataverse +`botId`, or a Graph audience group. Tenant identity comes only from the +authoring sign-in; never supply `tenantId` to a tool. + +The 100-current-item limit and latest-50 archived window apply separately to +each tenant-and-agent pair. There is no migration or tenant-wide fallback. +Tell the maker which selected agent they are managing: + +> These announcements belong to **{agent name}**. Their selected audiences apply +> within that agent, not across your other ESS agents. + +## Setup-state check + +For every request that reads or authors an announcement, read +`.local/config.json` before calling any MCP tool. + +If the file does not exist, or its `setup` value is not `"complete"`, show: + +> Welcome to the ESS Maker Kit. Before using `/org-announcements`, type `/setup` to set up your environment. + +and STOP. + +Requests that only ask what announcements are, or what this skill can do, do not +require local setup. Answer them directly. + +## MCP availability check + +Before any request that requires an announcement tool, inspect the tools +available in the current conversation for the `ess-org-announcements` server. + +When its tools are available, continue to **Resolve the target**. + +When its tools are unavailable: + +1. Run: + + ```text + python scripts/mcp_config.py validate --server ess-org-announcements + ``` + +2. Parse `MCP_CONFIG_STATUS_JSON:`: + - `configured`: follow **Start the announcements MCP server**. + - `missing-file` or `missing-server`: run: + + ```text + python scripts/mcp_config.py materialize-defaults + ``` + + Parse `MCP_CONFIG_RESULT_JSON:` and confirm `ess-org-announcements` + appears in `addedServers`, or run `validate` again and confirm its status + is `configured`. Then follow **Start the announcements MCP server**. + - command failure or any other result: show the exact error and stop. Do not + replace malformed JSON or overwrite an existing configuration. + +### Start the announcements MCP server + +Show: + +> The announcements server is configured, but its tools are not available in +> this chat yet. +> +> 1. Press `Ctrl+Shift+P`. +> 2. Run `MCP: List Servers`. +> 3. Select `ess-org-announcements`. +> 4. Choose `Start`. +> +> Type `done` when the server shows `Running`. + +Wait for the maker. When they confirm, inspect the available tools again. If the +tools are available, continue the original request. If they remain unavailable, +tell the maker to reload the VS Code window, rerun `/org-announcements`, and +stop. + +## Resolve the target + +Reuse the setup configuration already loaded. Do not guess an identifier or +use announcement content to decide which agent owns it. + +1. Select the active agent from the backward-compatible `agent` object. For + another configured agent, match an `agents` entry by `slug`, `botId`, or + unambiguous `name`. +2. Use the maker's explicit `titleId`, otherwise the selected entry's stored + `titleId`. A verified target can be reused for this conversation; do not + force `get_agent_config` before each opener. +3. If the title is missing, use this provider's read-only discovery tools: + `list_agent_configs`, then `search_agents` with a distinctive agent-name + substring when the list has no unambiguous match. These discover deployed, + tenant-visible agents; a `botId` is never a substitute for `titleId`. + Both tools belong to `ess-org-announcements`. No landing-page MCP process + is needed; follow this skill's availability check if they are unavailable. +4. Ask the maker to choose among ambiguous candidates. If no candidate matches, + stop and ask them to confirm the agent name and have the published agent + approved and deployed to the organization. Never fall back to tenant-wide + announcements. +5. Persist a discovered, verified `titleId` using the existing local-agent + convention: reread the complete config, find the selected `agents` entry by + `botId` then `slug` (name only if unambiguous), and change only its `titleId`. + If the active agent exists only as `agent`, copy that complete object into + `agents` first. Also update `agent.titleId` when the target matches + `activeAgent` or the active object's botId/slug. Preserve all other fields + and agents. Reread to verify both copies before calling another tool. + With no matching local entry, use the discovered title for this request + without fabricating a partial local agent. + +Discovery does **not** initialize landing-page configuration. Never call +`create_agent_config` or `update_agent_config` as part of this flow, including +when a target was found only through search. No landing-page existence check +or creation is needed to open announcements. + +## Hard rules + +1. Route every call to the `ess-org-announcements` MCP server through this + skill. +2. Call `open_org_announcements` **at most once per maker turn**. It is the only + announcement tool you may call to open a view. +3. `open_org_announcements` reads only. It never creates, updates, publishes, + archives, or deletes. +4. After the widget opens, **stop**. The widget owns every save, publish, + lifecycle action, and audience search for that editing session. Do not call + `save_bulletin`, `transition_bulletin`, or `duplicate_bulletin` — they are not + available to you, and asking for them is a bug. +5. Do not issue any further `search_audience_groups` call once the widget is + open. The widget performs its own searches. +6. Treat all suggested content as reviewable draft state. A suggestion is never + authorization to publish. Say what will open, not what was saved. +7. Never manufacture a bulletin ID, a status, an audit field, or a version. Only + the tools produce canonical state. +8. Never infer that an announcement was created, saved, published, archived, or + deleted from the request or opener alone. The widget reports normal success, + and chat must not duplicate its success message. The only exceptions are the + explicit `IndeterminateWrite` and `CommittedRefreshFailed` results described + below; report those outcomes exactly as instructed. +9. Use the announcement tools for server access. Do not call the backing REST + API directly. +10. Pass the resolved `titleId` on every opener. Only the widget may call + mutations, and it retains this title throughout navigation and retries. + On an agent change, open the new scope; never reuse the previous agent's + IDs, drafts, manager state, or retry request. +11. Keep `titleId` outside `suggestedDraft` and bulletin/editor content. + Audience search remains `{query}` in the tenant directory, not per-agent. + +## Classify the request + +Classify every announcement request into exactly one of these, then follow the +matching flow. + +| Maker intent | Flow | +|---|---| +| "Show me our announcements", "manage announcements", "what's published" | **Open management** | +| "Create an announcement", "new announcement", "let me write one" | **Empty create** | +| "Announce the benefits deadline to Finance", any request with real content | **Pre-hydrated create** | +| "Edit the all-hands announcement", "change the end date on X" | **Edit** | +| "Repost the parking notice", "that one expired, run it again" | **Edit** (review the schedule before publishing again) | + +When the intent is ambiguous, ask one short clarifying question before opening +anything. Opening the wrong view costs the maker a turn. + +### Open management + +Call `open_org_announcements` with: + +```json +{ "titleId": "", "view": "manager" } +``` + +Then stop and let the maker work in the widget. + +### Empty create + +Use this when the maker wants to write the announcement themselves. + +Call `open_org_announcements` with: + +```json +{ "titleId": "", "view": "editor", "mode": "create" } +``` + +Do not invent a title or description to "help". An empty create means empty. + +### Pre-hydrated create + +Use this when the maker's message already carries real announcement content. + +1. Build a `suggestedDraft` from what the maker actually said. Include only the + fields they supplied or clearly implied. Every omitted field falls back to + the editor default, which is what the maker would have seen anyway. +2. Resolve any named audience through `search_audience_groups` **before** the + opener call. See **Resolve suggested audiences**. +3. Call `open_org_announcements` once: + + ```json + { + "titleId": "", + "view": "editor", + "mode": "create", + "suggestedDraft": { + "type": "standard", + "priority": 1, + "title": "", + "description": "", + "primaryAction": { + "actionType": "externalLink", + "label": "