I run a blog agent that creates Blogger drafts and never publishes directly. It runs as an AWS Lambda function packaged as an ARM64 container image, its runtime credentials live in AWS Secrets Manager, and CI/CD authenticates to AWS with OIDC. I read every draft before it goes live. As a description of a workflow, that is fine. As a safety guarantee it is weak, because "a human reviews everything" says nothing about where the system makes publishing impossible. A bad refactor, a misread parameter or a model-generated change to the publishing step could quietly turn drafts into live posts.
This post looks at where that boundary can actually be enforced when the target is the Blogger API, why the credential cannot carry it, and where it has to be enforced instead. It also covers what is still rough, because the setup is not frictionless.
Where the setup stands today
The agent only ever creates drafts, and publishing is a manual step after review. Recently I added logging: every article is written to S3 before it is sent to Blogger. That leaves a record of exactly what the agent produced and handed over, which helps when a draft looks wrong or something unexpected shows up. It is an audit and detection aid, not a guard: the log is written before the request, so it cannot stop that request from publishing.
It is not all smooth. The agent sometimes consumes tokens without producing anything useful, which costs money. A 6,000-token limit seems to keep that under control so far. That is a separate problem from the publishing boundary, but the lesson is related: an agent is code that makes its own decisions, so both what it is allowed to do and how much it is allowed to spend need explicit limits.
For the draft flag itself, the current approach is the simplest one: isDraft=true is part of the URL constant, so no call site passes it and none can forget it.
private const string BloggerDraftUrl =
"https://www.googleapis.com/blogger/v3/blogs/{0}/posts/?isDraft=true";
That keeps the flag in one place, but it is still a convention enforced by my own code. The agent holds a credential that could call posts.publish if some code path did, and nothing structural prevents it. The narrow draft-only interface described later in this post is a design I have not implemented. It is something I may move to for a safer setup, not a description of the current code.
Four questions for any human-in-the-loop design
A review step is only as strong as the mechanism behind it. These questions help separate a real boundary from a convention:
- Where is the boundary enforced? In the credential, in the network, in code, or only in habit?
- What happens when someone forgets? A safe default fails closed. An unsafe one fails open.
- Is there a second control? One line of code should not be the only thing between a model's output and the public.
- Can a violation be detected? If something does go live, how soon will anyone know?
On AWS the usual answer to the first question is IAM: scope a role to exactly the actions a function needs. Blogger offers nothing equivalent.
Blogger has one write scope
The posts.insert reference lists a single scope for adding a post, https://www.googleapis.com/auth/blogger. The method that turns a draft into a live or scheduled post, posts.publish, lists the same scope. Alongside it there is a read-only scope, but nothing between "read" and "read and write everything".
The consequence is simple: a credential that can create a draft can also publish it. Narrowing the token, the way you would narrow an IAM policy, cannot enforce "never publishes". The boundary has to live in your own code, which is also the part most likely to change.
The default is the dangerous part
The insert reference lists isDraft as an optional boolean described as whether to create the post as a draft. It does not state what happens when the parameter is left out. Until you have verified the behavior on a throwaway blog, the safe assumption is that an omitted flag may produce a live post.
That makes the design fail open. A request that forgets isDraft, or that passes through a serializer which drops false and null values, can publish. The explicit request looks like this:
POST https://www.googleapis.com/blogger/v3/blogs/{blogId}/posts?isDraft=true
Authorization: Bearer {access_token}
Content-Type: application/json
{
"title": "Post title",
"content": "<p>Body HTML</p>",
"labels": ["Example"]
}
The query-string isDraft=true is the only thing that matters here, and nothing in the credential would stop the same call without it. Every code path that can build this request is therefore part of the safety boundary, including any path written or modified by a model.
Make "publish" something the agent cannot express
The cheapest strong control is to remove the capability from the agent's vocabulary. Instead of handing the agent a general Blogger client and trusting every call site to pass the right flag, give it one narrow interface. This is a design sketch in C#:
public interface IDraftSink
{
Task<string> CreateDraftAsync(DraftPost post, CancellationToken ct);
}
internal sealed class BloggerDraftSink : IDraftSink
{
// The only place isDraft is set: a literal, never a parameter.
private const string InsertQuery = "?isDraft=true";
// Build the request, call posts.insert, return the new post id.
}
Two properties make this useful. The agent depends on IDraftSink, which has no publish, update-to-live or schedule method, so adding one means adding a new interface, which is a visible and reviewable change. And the flag is a constant inside one class rather than a value passed through layers. Note that posts.publish also accepts an optional publish date for scheduling, so "schedule" belongs on the list of things the interface must not offer.
This is dependency injection applied to authority rather than testability: the type signature carries the policy. It is not a complete boundary, since the process still holds a credential that could publish if someone wrote the code. What it does is turn an invisible convention into a narrow seam that is easy to see in a diff and easy to test.
Add a second control that does not trust the first
An independent check should catch what the first control misses. Three options, from cheapest to most expensive:
| Control | What it catches | Trade-off |
|---|---|---|
| Request test in CI | Any change that sends an insert without isDraft=true | Only covers code paths the test exercises |
| Read-back after insert | A post that was created live despite the request | One extra call; verify which fields the API returns for your account |
| Pre-send log in S3 | Nothing by itself; gives a record of what the agent produced and sent | Written before the call, so it supports audits rather than prevention; needs access control and a retention policy |
| Scheduled sweep | Live posts the agent should never have created | Detection after the fact, not prevention |
The request test is the one worth writing first. A unit test against a fake HTTP handler can assert that every outgoing insert carries the flag, and because it fails the build, it also covers changes the agent or a coding assistant proposes:
[Fact]
public async Task Insert_always_sets_isDraft_true()
{
var handler = new CapturingHandler();
var sink = new BloggerDraftSink(new HttpClient(handler), "blog-id");
await sink.CreateDraftAsync(
new DraftPost("Title", "<p>Body</p>"), CancellationToken.None);
Assert.Contains("isDraft=true", handler.LastRequest!.RequestUri!.Query);
}
A read-back check can compare the stored post's status against what was intended and fail loudly on anything other than a draft. Whether the status field is returned depends on the request, so check the live API before relying on it. For the sweep, the API also has a revert method that returns a published post to draft. That gives you a recovery path, but a reverted post may already have been seen or cached, so treat it as damage control rather than protection.
What this says about agents in general
The lesson is not specific to Blogger. When a platform does not offer fine-grained permissions, a human-review boundary becomes an application-level invariant, and invariants decay unless they are tested. Output from an agent that reaches the public deserves the same treatment as any external input: validate its shape, and never let it choose its own authority. The same applies to code that an AI tool helps write. If it can touch the publishing path, review that path first, not the model's prose.
There are costs worth stating plainly:
- A narrow interface adds friction when you genuinely want a new capability. That is the intent, but it is still friction.
- Human review is a bottleneck. It protects the blog and also caps how much the agent can produce.
- The credential the agent holds is high-value, because it can do more than the agent is supposed to. I keep it in Secrets Manager, which reduces exposure but does not change what it can do.
- An agent also has a cost boundary. Mine sometimes spends tokens for no useful result, and a 6,000-token limit seems to work so far. Like the draft flag, it is a limit I enforce myself, so it is worth testing too.
A sensible order of work
- Find out where your platform's permission model stops. For Blogger, current documentation shows it stops at one read-write scope.
- Test the omitted-
isDraftbehavior on a throwaway blog so you know how the default fails. - Put the draft flag in a single place and expose no publish or schedule operation to the agent.
- Add a CI test that fails when an insert request lacks
isDraft=true. - Add a read-back or a periodic sweep if the cost of an accidental publish justifies it.
- Re-run the throwaway-blog test whenever the client code, the library or the API changes.
Platform behavior and API defaults can change, and a human-in-the-loop design is only as strong as the last time someone tried to break it.
Comments
Post a Comment